API Versioning Without Breaking Customers: Date-Pinned Versions Explained
Pin every account to the date it integrated, let any single request override it, and ship everything else as non-breaking.
Most small-shop APIs version by shoving a /v2/ into the URL, and then nobody wants to ship /v3/ because that means maintaining two codebases forever, so the API just quietly stops changing. I did that. It's a trap. The fix isn't a bigger number, it's a written rule about what counts as breaking, and then a header that carries the date.
REQUEST RESOLVER HANDLER
--------------------------- ------------------------ -------------------
no version header ----> account default date ---> code path for date
Api-Version: 2024-03-01 --> override, this call ----> code path for date
(account default
untouched)
|
v
[ response shaped to
the resolved date ]
Look at the resolver: the account's pinned date is the default, the header beats it for one call only, and nothing about sending that header changes tomorrow's calls.
Stripe does this and documents it plainly, and if you read through how Stripe handles API versioning with dated releases you'll see the whole policy is really two sentences: your account is pinned to the version that was current when you first integrated, and you can override it per request with a header. Everything else follows from that.
The part people skip is the policy. You have to write down what you consider backward compatible before you need it, because in the moment you will absolutely convince yourself that adding a required field is "just a small change." In my own back office, adding a new field to a response is non-breaking. Adding a new optional parameter is non-breaking. Returning a new value in an existing enum is non-breaking, which means every consumer has to tolerate unknown enum values, and I say that out loud in the docs so nobody builds a switch statement that explodes. Removing a field, renaming it, making an optional thing required, changing the shape of an error: those are the ones that get a date.
Then the mechanism is almost boring. The request comes in, I resolve a version date, I run the response through the transforms stacked between that date and today, and I ship. New integrations get the current date. Old ones keep getting exactly the payload they were built against, on purpose, until somebody decides to move. Upgrading becomes a thing a customer does with one header on one call in a staging environment, sees what breaks, and then flips the account default when they're ready. Not a migration project. A test.
Here's my mild gripe with the common advice: everybody tells small teams to "just use semver on your API." Semver is a signal, not a compatibility layer. It tells a customer their integration might break. It doesn't keep it alive. Dates plus transforms keep it alive, and honestly if you're a one-person shop selling to businesses that integrated once and never want to think about you again, staying alive is the entire product.
I'm not the type to pretend this is free. Every transform is code you keep, and you will eventually have a stack of them you're scared to touch, so write a test per transform the day you write the transform, and set an actual sunset policy for ancient dates instead of carrying 2019 forever out of guilt.
Write down what "breaking" means first, then let a date in a header do the enforcing.
Jesse