API Versioning Without Breaking Customers: Date-Pinned Versions Explained
Pin every account to the version it signed up on, let a header override it per request, and ship everything else as additive.
The scariest deploy I ever did was a rename. One field, snake_case to camelCase, took me about four seconds to type. It would have quietly broken every integration that was already reading that field, and nobody would have found out until an invoice didn't go out.
So I stole the pattern from the people who had already solved it. Stripe pins each account to a dated version, and their API versioning docs spell out the deal plainly: your requests use the version your account is on, and you can override it per request with a header.
ACCOUNT DEFAULT PER-REQUEST OVERRIDE
--------------- --------------------
acct_A -> 2024-03-11 POST /quotes
acct_B -> 2024-03-11 API-Version: 2025-01-20
acct_C -> 2025-01-20 |
| |
v v
+----------------------------------------------+
| router: pick version, then handler |
+----------------------------------------------+
| |
v v
2024-03-11 shape 2025-01-20 shape
{ line_items } { lineItems, tax_breakdown }
\ /
\ /
v v
+------------------------+
| ONE core service | <- business logic lives here
| (version-agnostic) | only, never forked
+------------------------+
Look at the bottom box: the versions fork at the edge of the response, not through the middle of your business logic.
Here's the part people skip. Before any of that works you have to write down what counts as breaking, and then actually live by it. In my own company's back office the rule is short: adding a new field is fine, adding a new enum value is fine, adding a new endpoint is fine. Removing a field, renaming one, changing a type, making an optional thing required, those get a new dated version. Anybody consuming my API, including my own phone assistant and the field-update app, has to tolerate fields it doesn't recognize. That's the contract on both sides.
Which means most releases need no version at all. That's the quiet win. I ship additive changes constantly and nobody downstream notices, and the dated versions pile up slowly, one every long while, usually because I finally admitted a shape was wrong.
The common practice I don't like is /v1/ and /v2/ in the URL. It feels tidy and it lies. You end up with two copies of the handler, then two copies of the service underneath it, then a bug fixed in one and not the other, and six months later nobody can tell you what v1 actually does. A date in a header is just a number the router reads. The code stays single.
The cost is real, I'm not gonna pretend. Every old version you keep alive is a translation layer somebody has to maintain, and if you're pre-customer, building versioning before you have anyone to break is just procrastination with good posture. Ship the ugly API first. Add the header the day a second party depends on you.
Backward compatibility is a policy you write down before it's a header you read.
Jesse