API Versioning

URI versioning, header versioning, semantic versioning. The choice that ages well vs the one that bites every quarter.

Concept Intermediate
10 min read
versioning evolution http rest contracts

Summary#

API versioning is the discipline of evolving an interface without breaking the clients that depend on it. The naive view is “v1, then v2, then v3” — three big-bang versions cleanly separated. The reality is messier: most successful APIs run a single major version for many years, accumulate hundreds of additive changes inside it, and reserve a “v2” jump for the rare moment when the data model fundamentally changes.

The two big surface choices are where the version lives — in the URI (/v1/charges), in a header (Accept: application/vnd.example.v2+json), or in a date (Stripe-Version: 2024-03-15) — and how often you bump it. URI versioning is the most visible and the most common; header versioning is cleaner in the REST-purist sense; date-based is what Stripe and GitHub have converged on for fine-grained evolution.

Underneath those choices is a deeper one: how breaking is “breaking”? Adding a new optional field is free. Removing a field is a six-month deprecation. Renaming an endpoint is a multi-year migration. The version number doesn’t make any of that easier — what makes it easier is a clear policy on what counts as breaking and how long the old behaviour stays around.

Semantic versioning (major.minor.patch) is the right vocabulary for libraries — code your consumers compile against. It is the wrong vocabulary for APIs over the network, where there is no “compile against this version” moment and clients in production are running every historical version simultaneously. APIs need a different model.

The right answer for most API teams is: additive forever inside the current major version, with rare breaking changes packaged as a brand-new major version that overlaps the old one for two or three years. The version-in-the-URI is then a coarse marker; the fine-grained evolution happens inside it.

Why it matters#

Three reasons versioning is a first-class API design concern:

  • Clients are pinned and you cannot move them. A mobile app shipped two years ago is still calling your API. A server-side integration written by a partner who has since left the company is still calling your API. You cannot force-upgrade these clients. Anything that breaks them is your problem, not theirs.
  • Breaking changes have a half-life longer than a quarter. Even with aggressive deprecation, a removed field that 90% of clients have stopped using still has 10% calling it on the day you turn it off — and that 10% is your largest customers, whose code review cycle is six months. Plan for years, not weeks.
  • The cost of getting it wrong is migration debt forever. An API that ships breaking changes recklessly trains its clients to defensively wrap every call. An API that never ships them collects archaeological layers that no one dares touch. The middle path — additive evolution plus rare disciplined breaks — is the only one that scales.

The senior-signal phrasing in an interview: “We don’t version per-change; we version per-data-model-revolution. Additive changes ship inside the current major version with no version bump. The few genuinely breaking changes that can’t be made additive get a new major version that runs in parallel with the old one for two-plus years.”

How it works#

Where the version lives — three placements#

URI versioning#

The version is part of the path: /v1/charges, /v2/charges. This is the most common pattern; GitHub, Twilio, Twitter, and most public APIs use it.

GET /v2/charges/ch_abc HTTP/1.1
Host: api.example.com
Authorization: Bearer ...

Pros: visible in every request; easy to route at the gateway (different version → different backend); easy to cache (different URL → different cache entry); easy to explain in docs.

Cons: violates the REST-purist view that a resource has one URI (the same charge ch_abc lives at both /v1/charges/ch_abc and /v2/charges/ch_abc). In practice, no one cares.

Header versioning (media-type)#

The version is in the Accept header, as a custom media type:

GET /charges/ch_abc HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json
Authorization: Bearer ...

Pros: the URL stays clean; the resource has one canonical path; content negotiation is the HTTP-native way to say “give me this representation”.

Cons: invisible in browser address bars and naive curl commands; harder to cache (caches must vary on the Accept header); harder for partners to debug (“what version am I on?” requires reading the request headers).

GitHub used this style historically (application/vnd.github.v3+json); they have since softened toward a date-based scheme alongside it.

Date-based versioning#

The version is an opaque date string, sent in a custom header:

GET /charges/ch_abc HTTP/1.1
Host: api.stripe.com
Stripe-Version: 2024-03-15
Authorization: Bearer ...

Pros: fine-grained — every breaking change gets its own date; clients pin to a specific date and migrate explicitly; the API team can ship breaking changes monthly without a “v3” trauma.

Cons: requires sophisticated server-side machinery (a version-translation layer that converts requests and responses between versions); high implementation cost; clients must explicitly opt into new dates.

Stripe is the canonical user; their API has hundreds of date-based versions accumulated since 2011, and every account has a pinned default version that they can override per request.

The right answer for most teams: URI plus additive evolution#

The pragmatic default for a new API:

  • URI versioning (/v1/). It’s the most visible and the easiest to operate.
  • One major version at a time, for years. v1 ships and stays the only public version. New endpoints, new optional fields, new optional query params — all additive, no version bump.
  • A “v2” jump only when the data model fundamentally changes. Renaming customer_id to account_id, restructuring the response from flat to nested, or changing the authentication model. Anything less should be additive.
  • v2 runs in parallel with v1 for 2-3 years. Both endpoints are live. Telemetry tracks which clients still use v1. The v1 sunset date is announced once v2 usage exceeds 80%.

This is the GitHub v3 → v4 (GraphQL) playbook, the Twilio v1 → v2 playbook, the AWS v2 → v3 SDK playbook. None of them ship a v2 lightly.

Stripe’s per-account version pinning#

Stripe’s date-based versioning has a non-obvious operational trick: every API key has a default version associated with it. When an account signs up in 2024-03, their default version is 2024-03-15. New code they write that doesn’t specify Stripe-Version gets that date. Old code keeps working forever at that date. The account can upgrade explicitly via the dashboard, but they never have to.

This means:

  • A breaking change ships as a new dated version; only accounts that opt in (or new accounts created after that date) see it.
  • Existing accounts run on their original version potentially forever — but the API team has telemetry showing the version-distribution and can plan a sunset when usage drops.
  • Per-request override (Stripe-Version: 2025-01-01) lets a client test against a newer version without flipping their default.

The cost is the version-translation layer: every request from an old version is upconverted to the latest internal schema, and the response is downconverted before returning. That’s a real engineering investment; it’s why most APIs don’t do this.

Semantic versioning is for libraries, not APIs#

major.minor.patch is the right vocabulary for client libraries — the stripe-python package, the @octokit/rest package. Libraries are compiled against by clients, and clients pin a version. SemVer tells them what to expect when they upgrade.

For APIs over the network, SemVer does not fit:

  • There is no “compile time” — clients call the API at runtime.
  • Every historical version is in use simultaneously, not just the latest.
  • Patch and minor versions add no value over the wire (clients don’t know or care which patch shipped yesterday).

The major number is the only one that matters at the wire level, and it’s coarse enough that calling it “v1” / “v2” without the rest is clearer than “v1.4.7”.

How Accept-based content negotiation actually works#

When using header versioning, the server uses standard HTTP content negotiation:

GET /charges/ch_abc HTTP/1.1
Accept: application/vnd.example.v2+json, application/vnd.example.v1+json;q=0.9

The client expresses a preference ordering with q= weights. The server picks the highest-preference version it supports and returns it with a matching Content-Type header. If none of the requested versions are supported, return 406 Not Acceptable.

In practice, most clients send a single Accept value and call it done. The full content-negotiation machinery is overkill for typical use.

Variants and trade-offs#

URI versioning. Version is in the path: /v1/charges. Visible, cacheable, easy to route. Violates “one resource, one URI” — but no one outside REST academia cares. The pragmatic default.

Header / date versioning. Version is in Accept or a custom header. The URL stays clean; routing and caching must vary on the header. More machinery, finer control. Stripe’s choice; only worth it at Stripe’s scale.

DecisionChoice AChoice B
PlacementURI (/v1/)Header (Accept or X-Version)
GranularityMajor versions only (v1, v2)Per-change dates (2024-03-15)
Default versionLatest at signup timeLatest always
Overlap policy2-3 years of dual-runningNew version replaces old immediately
TelemetryPer-version request countsPer-account version pinning
SemVer?No — libraries onlyNo — libraries only

The senior choice for most APIs: URI versioning, major versions only, additive evolution inside the current major, 2-3 year overlap when a v2 is justified. Reach for date-based versioning only when you have the engineering team to maintain a version-translation layer and the integration partners to justify it.

When this is asked in interviews#

API versioning comes up in three places:

  • In the API-evolution segment of a system-design interview. “How would you ship a new field?” — additive, no version bump. “How would you rename a field?” — additive (the new field, with a 2-year deprecation on the old one), no version bump. “How would you change the auth model?” — a new major version with parallel running.
  • As a sub-question in security or rate-limiting designs. “What if the old auth model is fundamentally insecure?” — that’s the case where a major-version bump is justified, with an aggressive sunset on the old version.
  • In comparing REST vs GraphQL design. GraphQL has a different versioning story (deprecate fields in the schema with @deprecated, evolve forever in one schema). The senior signal is naming both models and choosing.

Specific points to make:

  • Name the three placements (URI, header, date) and pick one with a reason.
  • Distinguish API versioning from library SemVer. They are not the same problem.
  • Specify the overlap window (2-3 years is the industry norm for major bumps).
  • Specify the telemetry that lets you make the sunset decision (per-version request counts, per-client distributions, sunset announcements).
  • Tie it to deprecation policy. Versioning without a deprecation timeline is wishful thinking; see evolving-an-api-design.

The strongest one-liner: “We don’t version per-change. We evolve additively inside v1 for years, and we only ship a v2 when the data model itself changes — with a multi-year overlap so existing clients have time to migrate.”

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.