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 additive.
The scary part of running an API isn't writing it. It's the morning you rename one field and some shop that integrated eight months ago stops getting invoices, and nobody notices until a customer calls asking why. I'm not the type to find out that way twice.
ACCOUNT DEFAULT PER-REQUEST OVERRIDE
(pinned the day they (one header, one call)
first called the API)
acct_a -> 2024-03-11 ──┐
acct_b -> 2025-01-20 ──┤ X-Api-Version: 2025-01-20
acct_c -> 2025-01-20 ──┤ │
▼ ▼
┌─────────────────────────────────┐
│ REQUEST RESOLVER │
│ header ? header : acct default│
└───────────────┬─────────────────┘
▼
┌─────────────────────────────────┐
│ CORE LOGIC (one code path) │
└───────────────┬─────────────────┘
▼
┌─────────────────────────────────┐
│ RESPONSE SHAPERS (by date) │
│ 2024-03-11 │ 2025-01-20 │ ... │
└─────────────────────────────────┘
Additive changes (new optional field, new endpoint)
skip the shapers entirely. Everybody gets them.
Look at the bottom box: the core logic never forks. Only the output shape does.
I stole the shape from Stripe. If you read how Stripe pins an account to the API version that was current when it was created, the whole thing clicks: your account has a version, a request can override it with a header, and anything backward compatible ships to everyone immediately without a new version at all.
That last clause is the part people skip. The reason date versions don't explode into forty forks is that most releases aren't breaking. Adding an optional field, adding an endpoint, adding a new enum value your clients already have to tolerate: none of that gets a date. You only cut a new version when you rename, remove, or change the meaning of something that already exists.
Which means the real work isn't the header. It's writing down, before you ship anything, what counts as breaking. Mine's a short list taped into the repo: removing a field is breaking, renaming is breaking, changing a type is breaking, tightening validation is breaking. Adding is not. New enum value is not, because I told integrators up front to ignore values they don't recognize. That list is the contract. The header is just how the contract gets enforced at runtime.
In my own company's back office, the same system quotes, invoices, and pushes field updates, and other shops hit pieces of it through Art3ry builds. Every one of those accounts is pinned to the date it came online. When I reshape an invoice payload, the old accounts keep the old shape until somebody deliberately bumps, and they can test the new one on a single call by setting the header before they commit to anything. Nobody gets surprised on a Tuesday.
The common practice I'll argue with: v1, v2, v3 in the URL path. It sounds tidy and it's lazy, because v2 means "a bundle of unrelated changes we felt like shipping together", so upgrading is all-or-nothing and your customers put it off for two years. A date means one dated decision. Smaller diff, smaller fear.
Decide what "breaking" means in writing first; the version header is only how you enforce it.
Jesse