Introduction
APIs are built for developers to use - and successful APIs are designed with developer experience in mind. Part of a good developer experience is consistency, whether that’s in the design of the path names (all being either snake_case
or kebab-case
) or every entity having an id
property with its unique identifier. When adding new features to an API, its important to refer to existing patterns so that we can provide a predictable interface. Today you’ll learn the design patterns Stripe engineers use when adding date and time fields to the API.
Imagine you want to add a new feature to a Subscriptions API. Users report that one of their challenges is canceling subscriptions at a later date. They are building complex workarounds with cron jobs and would rather tell Stripe when the Subscription should be canceled. Here’s a basic product spec:
While creating or updating a Subscription with the API, users can pass a date and time in the future when they want the Subscription to cancel and if the user retrieves a Subscription from the API, they know when the subscription was canceled if at all.
Let’s break that down:
- “While creating or updating a Subscription with the API” → this feature will need to work for both create and update endpoints.
- “users can pass a date and time in the future” → we need to receive the date and time from the user as an argument to the API.
- “when they want the Subscription to cancel” → the new date and time should be stored somewhere so that we can use it in a new background job that will cancel subscriptions.
- “if the user retrieves a Subscription from the API” → this is a different feature for the retrieve endpoint.
- “they know when the subscription was canceled” → we need to render the date and time that the subscription was canceled.
- “if at all.” → It’s possible the property on the Subscription that represents when it was canceled is null (if it is not canceled).
This task involves at least three features to implement, and will allow us to talk about both datetime
arguments to the API and datetime
properties of objects returned from the API. Before enumerating possible solutions and tradeoffs, let’s talk about some ways that working with date and time is challenging.
Why working with date and time is challenging
If you’ve tried to build a calendar interface, or localize a datetime
to user specific timezones, then some of these challenges are familiar:
Timezones are surprisingly complex
Writing code to work well with timezones is confoundingly difficult [0], [1]. Timezones also add operational overhead. Maybe you’re based in San Francisco and forget to deploy a billing change before your customers in Sydney start getting charged for next month — woops. Here’s a spooky one: in the State of Indiana, timezones differ by county.
Months have different numbers of days
Because the last day of the month might fall on 28, 29, 30, or 31, it can be challenging to automate tasks to happen “on the last day of the month”. There is certainly some bad code floating around that switches based on the month to derive the last day:
require 'time'
def last_day_of_month?
case Date.today.month
when 1, 3, 5, 7, 8, 10, 12
Date.today.day == 31
when 4, 6, 9, 11
Date.today.day == 30
else
Date.today >= 28 # 🐛
end
end
If you’re not convinced, check out this StackOverflow Question about how to calculate last day of month with JavaScript. It has been viewed 430,000+ times and has 25 different answers!
Stripe Billing will derive the last day of the month for you, by the way. If a customer’s monthly subscription is anchored to August 31st, they will be charged on September 30 and February 28th.
Local date formats differ by the order of day, month, and year
The ECMAScript standards body maintains an API purely for localizing dates. Here are some examples from the MDN documentation:
const date = new Date(Date.UTC(2012, 11, 20, 3, 0, 0));
// US English uses month-day-year order and 12-hour time with AM/PM
console.log(date.toLocaleString('en-US'));
// → "12/19/2012, 7:00:00 PM"
// British English uses day-month-year order and 24-hour time without AM/PM
console.log(date.toLocaleString('en-GB'));
// → "20/12/2012 03:00:00"
// Korean uses year-month-day order and 12-hour time with AM/PM
console.log(date.toLocaleString('ko-KR'));
// → "2012. 12. 20. 오후 12:00:00"
Color | Order styles | End |
---|---|---|
Cyan | DMY | L |
Yellow | YMD | B |
Magenta | MDY | M |
Green | DMY, YMD | L, B |
Blue | DMY, MDY | L, M |
Red | MDY, YMD | M, B |
Grey | MDY, YMD, DMY | M, B, L |
Storing local timezones doesn’t scale
If you use a local timezone for a datetime
stored in your database, then attempting to scale across multiple regions introduces new problems.
12 hour vs. 24 hour clocks
The 24 hour clock is the standard, but the 12 hour clock is more dominant in countries that were once part of the British Empire[2]. In some places the 12 hour clock is used verbally, but the 24 hour clock is used in written material.
Leap year
Our trip around the sun is 365 days, 5 hours, 59 minutes and 16 seconds instead of a perfect 365. Leap years in our Gregorian calendar are calculated by adding extra days in each year that is an integer multiple of 4 (except for years evenly divisible by 100, but not by 400) [3]. The Julian calendar from Roman times solved this with a quadrennial intercalary day as a 48-hour February 24, see bissextile!
The list in the draft of this article was longer so I hope you’re convinced by this abbreviated version. Next, we’ll discuss API designs that are scalable, avoid timezone trapdoors, leap second pitfalls, and can be consumed by anyone in the world.
Options for working with dates and times in an API
Most web APIs today use JSON for serialization and deserialization. JSON doesn’t have a native date
, time
, or datetime
scalar data type, so we’ll need to use either String or Number to represent our values.
Arbitrary string
As a naïve early-career developer, I would’ve likely designed my internal APIs to work with string values and picked an arbitrary string that was easy to read as a human. Something like:
m/dd/yyyy hh:mm:ss
Pros:
- Easy to read (if you know the format!)
Cons:
- Not a widely accepted format
- No concept of timezone
- Doesn’t localize well to other regions
- Requires knowing whether the middle and last date part are the month or the day. E.g. is
8/9/2022
August 9th or September 8th? - Slashes in the date mean it does not play nicely with URL paths
- Strings are heavier weight in the database
This is for illustration, please never do this in your API. Instead, select a format that is agreed upon by a recognized standards body.
Separate key and value for each date part
Instead of accepting the datetime
as a single value, we could accept each part in a structured object. For example:
{
"id": "sub_abc123", # ID of the Subscription to cancel.
"cancel_at": {
"year": 2022,
"month": 8,
"day": 9,
"hour": 8,
"minute": 54,
"second": 0,
"timezone-offset": "+05:00"
}
}
Pros:
- We avoid string format inconsistencies across locales
Cons:
- Users of the API still need to deal with timezones
- While unlikely, the hour passed by a user could mistakenly be 8:54 p.m. a.k.a 20:54
- What if we need sub-second accuracy? You might expect to see
second: 0.00123
etc, ormilliseconds: 123
- neither of which map well to our colloquial understanding of time
Before we completely dismiss this approach, let’s look at an example without the time component. Here’s an example for creating a Person with a name and date of birth:
{
"first_name": "Jenny",
"last_name": "Rosen",
"date_of_birth": {
"year": 1901,
"month": 1,
"day": 9,
}
}
This structure works great for dates without times!
Pros:
- Avoids any confusion about local formatting for dates like
9-1-1901
vs.1-9-1901
- Avoids string manipulation
- Simple
Cons:
- Implementation code must handle each part individually instead of one value
ISO 8601
The International Organization for Standardization (ISO) [4] defines an “international standard covering the worldwide exchange and communication of date and time-related data” -- ISO 8601 [5]. The remarkably comprehensive specification and includes support for durations and time intervals.
Here are some examples of a datetime
with timezone in ISO 8601 format:
2022-09-12T14:42:04+00:00
2022-09-12T14:42:04Z
Pros:
- Widely accepted standard
Cons:
- Some developers may resort to string manipulation
- Strings are heavier weight in the database
- Users still need to think about timezones
- More than one value can represent the same moment. E.g.
1994-11-05T08:15:30-05:00
and1994-11-05T13:15:30Z
are the same! [6]
If this is the option you decide to use, consider accepting arguments with any timezone offset, then only responding with dates and times based in UTC time.
Unix time
In computing, Unix time (also known as Epoch time, Posix time, seconds since the Epoch, Unix timestamp or UNIX Epoch time) is a system for describing a point in time. It is the number of seconds that have elapsed since the Unix epoch, excluding leap seconds. The Unix epoch is 00:00:00 UTC on 1 January 1970. [7]
{
"id": "sub_abc123", # ID of the Subscription to cancel
"cancel_at": 1662738023,
}
Pros:
- Unix time does not require users to reason about timezones
- Unix time is a single signed number
- Because integers are easier to query, index, and more space efficient than strings, dates are often stored as 64-bit integers in the database
- Use negative numbers for moments before January 1st, 1970
- Unix time has a single format
- Unix time is not effected by daylight savings
- Unix time is a widely supported standard
- Easy to convert to a nice localized format client side using a library like date-fns, or day.js
- You can get a cool shirt with the current time https://datetime.store/
Cons:
- Not human readable. E.g. how long ago did
1662738023
happen? is it in the future? - Unix timestamps for dates after 03:14:07 UTC on 19 January 2038 require more than 32 bits to represent. This is known as the Year 2038 problem
Stripe’s API design patterns for representing dates and times
🟢 GOOD: Use unix timestamps to represent dates with times
This is the case for most fields and parameters and are represented as a Timestamp fields and parameters in the API. Timestamps in the API are represented as an integer denoting a Unix timestamp, which counts the number of seconds since the Unix epoch.
curl https://api.stripe.com/v1/subscriptions/sub_1LhfXp2Tb35ankTnpxovpHQs \
-u sk_test_: \
-d "cancel_at"=1662738023
{
"id": "sub_1LhfXp2Tb35ankTnpxovpHQs",
"cancel_at": 1662738023
}
🟢 GOOD: Use hashes with day
, month
, and year
properties to represent birth dates
Integrations collect birthdays as day, month, and year separately. This hash syntax allows them to construct and read the value without parsing it into a string. See Issuing Cardholder or Account as an example of this format.
curl https://api.stripe.com/v1/accounts/acct_1KtbGiBdyCgEshX8/persons \
-u sk_test_: \
-d first_name=Jenny \
-d last_name=Rosen \
-d "dob[year]"=1901 \
-d "dob[month]"=2 \
-d "dob[day]"=12
{
"id": "person_1LhfZ7",
"first_name": "Jenny",
"last_name": "Rosen",
"dob": {
"year": 1901,
"month": 2,
"day": 12
}
}
🔴 BAD: Use any other format representing dates or times.
Stripe’s API design patterns for naming datetime
fields
Timestamp fields are named **<verb>ed_at**
The _at
pattern signals that the integer is always a unix timestamp. The _at
suffix is not allowed for non-timestamp fields. Some fields like the created
timestamp on all resources, do not conform to the _at
format and likely predated this design pattern.
🟢 GOOD: subscription.canceled_at
, invoice.finalized_at
🔴 BAD: subscription.canceled
, subscription.canceled_on
, subscription.cancellation_date
- For future timestamps, prefer
<verb>s_at
; e.g.file_link.expires_at
. But beware that future timestamps may imply an SLA. - This is just a general guideline and there are some good reasons to break it. For example,
dispute_evidence_details.due_by
,coupon.redeem_by
, andissuing_card_shipping.eta
do not use the_at
suffix, but are more clear.
Expiration times are represented as **expires_at: <unix timestamp>**
🟢 GOOD: file.expires_at
🔴 BAD: file.expires_after
, file.expiration_date
- If expiration is handled by a cronjob, it’s okay for that cronjob to lag slightly. E.g. if there’s a
status
field that changes frompending
toexpired
, and the user fetches the object a little while afterexpires_at
, it’s okay if they still seepending
. - Double-check
expires_at
when an operation is performed. For instance, if there’s a URL that points to a hosted page, we should checkexpires_at
when the hosted page is loaded. - Why not
expires_after
? It could be mistaken for “expires after N days” rather than “expires at, or very soon after, a givendatetime
.” It also doesn’t encourage tighter runtime checks mentioned above; users appreciate tighter contracts when we can give them.
Developer Experience Improvements
Syntactic Sugar Shortcuts
Stripe also adds a tasty developer experience enhancement for working with dates. Some endpoints accept magic strings in lieu of integer timestamps to make them even easier to work with. For instance, one common use-case when passing datetimes to an API is to pass the current date and time. Rather than needing to calculate that, if you know you always want to pass “now” then you can use the magic string **now**
e.g. for setting the billing cycle anchor which determines what day each month to charge for a subscription [8].
You can imagine other ways this might be extended to accept arguments like subscription_end
or tomorrow
. If you have ideas for others we’d love to hear about them in the comments below!
Test Clocks
Test clocks [9] are a feature of the API that enable you to simulate moving through time. You create a Test Clock object, then create a related Customer with that Test Clock, then you can advance the clock to a future time to test your webhook handling logic will properly deal with the events that fire for that period.
Here’s a compact example:
// Create a test clock
const testClock = await stripe.testHelpers.testClocks.create({
frozen_time: 1635750000,
name: 'its a date demo',
});
// Create a customer with the test clock
const customer = await stripe.customers.create({
email: 'jenny.rosen@example.com',
test_clock: testClock.id,
payment_method: 'pm_card_visa',
invoice_settings: {default_payment_method: 'pm_card_visa'},
});
// Create a subscription for the customer
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{
price: 'price_1LcssrCZ6qsJgndJ2W9eARnK'
}],
});
// Advance time
const testClock2 = await stripe.testHelpers.testClocks.advance(
testClock.id,
{frozen_time: 1641020400}
);
Code samples
How to convert a unix timestamp into a native date
, time
, or datetime
object in your lingua franca.
Ruby
Time.at(1662759630)
Python
from datetime import datetime
datetime.utcfromtimestamp(1662759630)
PHP
DateTime::createFromFormat( 'U', 1662759630 )
JavaScript
new Date(1662759630 * 1000)
Go
time.Unix(1662759630, 0)
Java
new java.util.Date((long)1662759630 * 1000);
.NET
DateTimeOffset.FromUnixTimeSeconds(1662759630);
Conclusion
Unix timestamps and structured date of birth objects provide many benefits for API consumers. We’re always flexible and open to adjusting to a better way of doing things. Let us know your preferred approach when working with date and time data in your API.
About the author
CJ Avilla (@cjav_dev) is a Developer Advocate at Stripe, a Ruby on Rails developer, and a YouTuber. He loves learning and teaching new programming languages and web frameworks. When he’s not at his computer, he’s spending time with his family or on a bike ride 🚲.
P.S.
Do you think one day, tombstones will have unix timestamps?
Jenny Rosen, lived 554805154
- 3067403554
, beware the Ides of March.
Top comments (1)
Great write up on a complex problem. I like the idea of the proposal to accept ISO date-times with offsets but to always return it at UTC!