API Versioning Without Breaking Customers: Date-Pinned Versions Explained
Pin a version to the account, let a header override it per request, and ship everything non-breaking in between.
Most small shops version their API by renaming the endpoint. /v2/quotes shows up, /v1/quotes rots, and some customer who integrated eighteen months ago finds out on a Tuesday that their invoices stopped posting. I did that once. Never again.
The better model is older than most of us think, and Stripe's docs on dated API versions lay it out plainly: every account has a version, the version is a date, and that date freezes the response shape until you deliberately move it.
ACCOUNT DEFAULT PER-REQUEST OVERRIDE
+----------------------+ +------------------------------+
| acct_7f2 | | Stripe-Version: 2024-04-10 |
| version: 2023-08-16 | | (header wins, this call only)|
+----------+-----------+ +---------------+--------------+
| |
v v
+--------------------------------------------------+
| VERSION RESOLVER |
| header ? header : account.version |
+--------------------------+------------------------+
|
v
+--------------------------------------------------+
| CORE HANDLER (one code path, newest shape) |
+--------------------------+------------------------+
|
v
+--------------------------------------------------+
| RESPONSE TRANSFORMS, newest -> oldest, in order |
| 2024-04-10: rename total_cents -> total |
| 2023-11-02: split name -> first/last |
+--------------------------------------------------+
|
v
old client, happy
Look at the bottom box: one handler, then a stack of small transforms that walk the response backward in time to whatever date the caller is pinned to.
That's the whole trick. The core of my system only ever knows the newest shape. The old shapes are a chain of tiny, boring, individually testable functions that undo a change. Rename a field back. Fold two fields into one. Drop a key that didn't exist yet. Each one is maybe fifteen lines and it never has to think about the handler.
In my own back office, the integrations that matter are the ones I'd rather not touch: intake coming off a form, quotes going out, invoices coming back, the phone assistant writing notes into the same record as the field updates. When I add a field to a quote object, nothing moves. Adding is non-breaking. Renaming, removing, retyping, or changing what an enum can contain is breaking, and that goes behind a new dated version. I wrote that list down before I wrote any code, because the list is the actual product. The header is just how the list gets enforced.
And here's my mild gripe with how most people do it: they treat versioning as a release process instead of a promise. They bump /v2 whenever a sprint ends, which teaches customers that every version is scary and none of them mean anything. A dated version means something specific. It means I committed to this exact shape on this exact day and I'll keep honoring it until I tell you otherwise, in writing, with a date.
Upgrading then becomes calm. A customer sets the header on one call, in staging, sees the new shape, fixes their parser, then flips the account default when they're ready. Nobody gets a Tuesday surprise.
Write down what counts as a breaking change before you ship the API; everything after that is just a header.
Jesse