Versioning & API evolution
Published
Why this, first. “How do you version a public API?” is a staple senior question
— and the mid-level reflex is to launch straight into /v1 vs. headers. The senior
move is to reframe: it’s really “how do I evolve the API without
breaking the clients I can’t redeploy?” You version only when an evolution is genuinely
impossible to make compatibly. This lesson makes that frame — and the breaking-vs-non-breaking
line, the three placement strategies, and the deprecation lifecycle — something you can
draw.
A version is a last resort
so evolve compatibly first
only add, never remove
and clients tolerate the rest
♻️ Memory rule: Evolve compatibly first — only ADD (new optional fields, new endpoints), never remove, rename, or repurpose; build tolerant readers (Postel’s law: ignore unknown fields). Version only when a change is genuinely breaking.
The line that decides everything
Every evolution question collapses to one classification: is the change breaking or non-breaking? Get this line right and the rest is mechanics.
✓ Truth: pure additions are non-breaking — ship them in place, no version, if your readers are tolerant.
Where the version lives: three strategies
Once a change is genuinely breaking, you must place the version somewhere. There’s no free option — each buys clarity in one dimension and pays for it in another.
How the exemplars actually do it
Two reference points worth quoting by name, because they encode the senior instinct — never break a deployed client silently.
Stripe · pin per account Your version is fixed when you first integrate; you upgrade explicitly, and a single request can override with a header. A server-side change never silently reshapes an existing integration. 1
GitHub · date in a header A date-based version sent in X-GitHub-Api-Version
— the header strategy paired with calendar versioning (2024-10-01).
2
Date vs semver = labeling 2024-10-01 vs v2 is a labeling
choice, not a strategy. Both expose only major versions; minors/patches are
the compatible changes you ship in place.
Pinning is the real move Per-account pinning converts “we changed the API” from a client-breaking event into a client-controlled one. That’s the senior signal.
Deprecation is a lifecycle, not a delete
The version is the easy half. Retiring the old one without burning integrators is where seniority shows.
Deprecation + Sunset (RFC 8594) ride in every response so even silent
clients carry the warning; you sunset only once real usage on v1 has genuinely drained.4 1 · Announce & overlap Publish the change, then run old and new in parallel for a long, generous window — months for a public API, not days.
2 · Signal in-band Emit Deprecation and Sunset (RFC 8594)
with the retirement date, so even silent clients carry the warning.
3 · Communicate out-of-band Changelog, email, docs — reach the humans, not just the code.
4 · Monitor, then remove Watch real traffic on the old version; sunset only once usage has drained, not on a calendar guess.
Pinning is the move that scales. It turns “we changed the API” from a client-breaking event into a client-controlled one — additive-first, then major, parallel, signaled, monitored, retired.
Retrieval practice
Close the lesson in your mind first, then answer from memory. Effortful recall is what converts “I read it” into “I own it.” Immediate feedback below.
Q1. Which API change is genuinely breaking?
Q2. The senior reframe of "how do you version?" is…
Q3. A "tolerant reader" client is one that…
Q4. The RFC 8594 Sunset header is used to…
Rehearse the answer out loud
Interviewer: "We have a public API with thousands of third-party integrations. We need to change the shape of the user object. How do you version it?" ~60 seconds, from memory (no scrolling up) — then reveal and compare.
“First I’d ask whether the change can be additive. If we can add the new field alongside the old, deprecate the old, and rely on tolerant readers to ignore what they don’t use, we avoid a version bump entirely — the cheapest outcome for thousands of integrators we can’t redeploy.
If it’s genuinely breaking — renaming, retyping, or dropping a field clients
depend on — then I introduce a new major version (date-based like Stripe and
GitHub, or /v2). Critically, I pin existing accounts to their current
version so nothing breaks silently, run both in parallel, and emit
Deprecation + Sunset (RFC 8594) headers. Then a migration guide, a
generous timeline, and I monitor real usage before sunsetting. On placement
I’d default to the URI path for visibility and routing — accepting it’s ‘less pure REST’ — or
a header for clean URLs at the cost of discoverability.”
Why this scores: it avoids needless versions, prevents silent breakage via per-account pinning, and walks a real deprecation lifecycle — additive-first, then major, parallel, signaled, monitored, retired.
I’m your teacher — ask me anything. Want to drill the breaking-vs-non-breaking line on edge cases? Curious how Stripe’s per-account pinning is implemented under the hood? Want me to grade your rehearsal answer or fire harder follow-ups about deprecation timelines? Just say so in the chat.
Primary source (read this next)
📖 “API versioning” — Stripe API docs: the clearest production treatment of per-account pinning and explicit upgrades. Pair it with GitHub’s API versions for the date-based-header approach.
Interview — pick your bar
Answer out loud in ~60s, then reveal. Core = recall · Senior = trade-offs & failure modes · Staff = synthesis under ambiguity · System Design = open design round (a different axis, not a harder level).
What is a breaking change?
Any change that can cause a deployed client to fail without the client changing its own code. The line that decides versioning: if a change can break an integration silently, it's breaking; if it can't, it's safe to ship in place.
What is a "tolerant reader"?
A client that ignores fields and values it doesn't recognize instead of failing on them. It's the client half of additive evolution: the server only adds, the client only tolerates, and you can ship new fields and enum values without breaking anyone.
What are the three places to put a version?
(1) URI path — /v1/users; (2) custom header — e.g. Accept-Version: 2 or API-Version; (3) media-type negotiation — Accept: application/vnd.company.v2+json. None is free; each buys clarity in one dimension and pays for it in another.
What does the Sunset header do?
Defined by RFC 8594, it carries the date an endpoint or version will be retired, in every response on the old version — so even silent clients that never read a changelog carry the warning in-band.
"How do you version a public API?" What's the senior reframe?
The question is really "how do I evolve the API without breaking the clients I can't redeploy?" A new version is a last resort, not a first move. Most changes can be made compatibly — additive change plus tolerant readers — with zero version bump. You version only when a change is genuinely impossible to make compatibly. Leading with /v1 vs. headers is the mid-level reflex; reframing to evolution is the senior signal.
Why is "just cut a new version" the wrong default?
The cost of a version isn't the URL — it's the permanent support burden: a migration imposed on every integrator, a fragmented client base, and a combinatorial testing matrix across parallel surfaces you now maintain forever. Mint one per change and you're running five APIs in perpetuity. Versions are expensive; you spend one only when compatible evolution is genuinely impossible.
Why is per-account pinning (Stripe) such a strong pattern?
It converts "we changed the API" from a client-breaking event into a client-controlled one. Existing integrations stay frozen on the version they were built against; nothing reshapes underneath them. New users get the latest; upgrades are opt-in and overridable per request. It's the cleanest way to ship breaking changes without a flag day for every integrator.
How do you decide when it's safe to actually remove the old version?
Monitor real traffic, don't guess on a calendar. Instrument usage per version (and ideally per consumer), watch it drain, and reach out directly to the stragglers still on the old surface. Sunset only when usage has genuinely gone — a date in the Sunset header is a target, not a guillotine. Pulling a version with live traffic on it because "the date arrived" is how you cause an outage you own.
A field is technically optional but every client has always sent it. You want to drop it from responses. Breaking?
Treat it as breaking in practice. The contract says optional, but the de facto contract is what clients actually depend on (Hyrum's Law: with enough consumers, every observable behavior is depended on by someone). Seniority is measuring real usage before deciding, not arguing from the spec. Check traffic; if anyone reads it, removing it breaks them.
How do you coordinate a migration across clients you don't control?
You can't force them, so you make migration cheap and the status visible. Instrument per-consumer usage of the old version. Publish a precise migration guide and, ideally, automated diffs or a compatibility shim. Reach the long tail individually — the top consumers by volume are a handful of conversations. Use in-band Deprecation/Sunset headers so even silent clients carry the signal. Then sunset by drained traffic, segmenting stragglers rather than pulling on a date. The work is communication and measurement, not code.
How do you keep the support cost of multiple live versions from compounding?
Hold a hard line on the number of concurrent majors (often N and N−1 only), so the testing matrix and bug-fix surface stay bounded. Where possible, implement old versions as a translation layer in front of a single current core — adapters that reshape requests/responses rather than forked codepaths — so business logic isn't duplicated per version. And keep the bar for cutting a version high: every version you don't mint is support cost you never pay. The cheapest version is still no new version.
- Classify the change. Breaking vs non-breaking is the line that decides everything. Apply additive-change discipline first: add optional fields/endpoints, never remove, rename, retype, or repurpose — most "version" requests dissolve here with zero migration.
- Decide where the version lives only if a bump is unavoidable: URI path (visible, routes/caches by URL, but couples version to identity), custom header (clean URLs, but invisible and needs
Vary), or media-type negotiation (most RESTful, but poor tooling support). State the pick and its trade-off. - Define the compatibility contract. Spell out tolerant-reader expectations on both ends — server append-only, clients ignore unknowns and open-enum — so additive evolution actually holds in practice, including in generated SDKs.
- Pin to insulate clients. Per-account (Stripe) or dated-header (GitHub) pinning converts "we changed the API" from a client-breaking event into a client-controlled one; new breaking surfaces never reshape existing integrations silently.
- Run a deprecation lifecycle. Announce → run old + new in parallel → emit in-band
Deprecation+Sunset(RFC 8594) headers → publish a migration guide with a generous window → reach humans out-of-band. - Migrate via dual-running / translation layer. Implement old versions as adapters in front of one current core rather than forked codepaths, so business logic isn't duplicated per version.
- Observe version usage, then retire on drained traffic — per-version (ideally per-consumer) instrumentation tells you when it's safe to sunset; never pull on a calendar guess. Bound concurrent majors (often N and N−1) to cap the testing matrix, support, and cost of N live versions.
Design a versioning & deprecation strategy for a public payments API with thousands of integrators.
A strong answer covers: leading with evolution, not versioning — additive-first so most changes ship in place with zero migration for clients you can't redeploy. For genuinely breaking changes: pin a version per account (Stripe's model) so a server change never silently reshapes a live integration; new accounts get the latest, upgrades are explicit and per-request overridable. Choose placement deliberately (URI path for visibility/routing, or a dated header à la GitHub) and name the trade-off. Run the full deprecation lifecycle: parallel old/new, in-band Deprecation + Sunset (RFC 8594) headers plus out-of-band changelog/email, a generous months-not-days window scaled to blast radius. Instrument per-consumer usage, retire only on drained traffic, and segment stragglers (reach the high-volume few directly) rather than pulling on a date. Bound concurrent majors (N and N−1) and implement old versions as a translation layer over one core to cap the cost, testing matrix, and operational complexity of running N live surfaces. For payments specifically, weigh data-correctness and security: sometimes the contract itself is the bug and a forced breaking change is the only honest fix — done with pinning, hard comms, and migration help.
You must make a breaking change to a widely-used response shape — design the rollout so no client breaks.
A strong answer covers: first exhausting the additive path — add the new field/shape alongside the old, keep the old populated, document it deprecated, and rely on tolerant readers to ignore what they don't use, so it's a zero-version outcome. If the change is truly incompatible (renamed/retyped/dropped field, changed meaning): cut a new major, pin existing accounts to their current version so nothing reshapes underneath them, and run both surfaces in parallel behind a translation layer over a single core. Signal in-band with Deprecation + Sunset (RFC 8594) headers so even silent clients carry the warning, and out-of-band with a migration guide and direct outreach to top consumers. Instrument per-version (ideally per-consumer) traffic, give a generous window, and retire only once usage has drained — segmenting the long tail rather than enforcing a calendar date. The discipline throughout: never break a deployed client silently; make migration cheap and the deprecation status visible in both the code path and to the humans who own it.
📄 Keep handy: Caching, pagination & versioning reference
These notes reflect my current understanding and are updated as I learn, build, and discover better explanations.