DEV Community 👩‍💻👨‍💻

Cover image for How Stripe designs for dates and times in the API
CJ Avilla for Stripe

Posted on

How Stripe designs for dates and times in the API

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.

Map of counties in Indiana with red and yellow coloring based on the timezone of the county https://en.wikipedia.org/wiki/Time_in_Indiana.

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Date format usage map from https://en.wikipedia.org/wiki/Date_format_by_country

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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, or milliseconds: 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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 and 1994-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,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
{
  "id": "sub_1LhfXp2Tb35ankTnpxovpHQs",
  "cancel_at": 1662738023
}
Enter fullscreen mode Exit fullscreen mode

🟢 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
Enter fullscreen mode Exit fullscreen mode
{
  "id": "person_1LhfZ7",
  "first_name": "Jenny",
  "last_name": "Rosen",
  "dob": {
    "year": 1901,
    "month": 2,
    "day": 12
  }
}
Enter fullscreen mode Exit fullscreen mode

🔴 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, and issuing_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 from pending to expired, and the user fetches the object a little while after expires_at, it’s okay if they still see pending.
  • Double-check expires_at when an operation is performed. For instance, if there’s a URL that points to a hosted page, we should check expires_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 given datetime.” 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}
);
Enter fullscreen mode Exit fullscreen mode

Code samples

How to convert a unix timestamp into a native date, time, or datetime object in your lingua franca.

Ruby

Time.at(1662759630)
Enter fullscreen mode Exit fullscreen mode

Python

from datetime import datetime
datetime.utcfromtimestamp(1662759630)
Enter fullscreen mode Exit fullscreen mode

PHP

DateTime::createFromFormat( 'U', 1662759630 )
Enter fullscreen mode Exit fullscreen mode

JavaScript

new Date(1662759630 * 1000)
Enter fullscreen mode Exit fullscreen mode

Go

time.Unix(1662759630, 0)
Enter fullscreen mode Exit fullscreen mode

Java

new java.util.Date((long)1662759630 * 1000);
Enter fullscreen mode Exit fullscreen mode

.NET

DateTimeOffset.FromUnixTimeSeconds(1662759630);
Enter fullscreen mode Exit fullscreen mode

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

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.

Drawing of tombstone with unix timestamps for birth and death dates

Top comments (1)

Collapse
 
savagealex profile image
Alex Savage

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!

What image format should you use in your next project? 🤔