The Runtime Theory
Software Architecture

API Versioning Strategies: URLs, Headers, and Compatibility Windows

URL versioning, header negotiation, and content negotiation — how each mechanism works at the HTTP level, what a breaking change actually costs, and how long you must support old clients.

The Runtime Theory Team3 min read#api#versioning#breaking-changes#rest
On this page

A public API is a contract with clients you cannot see, cannot patch, and cannot rebuild. Every breaking change forces every consumer to do migration work on your schedule — or silently breaks them. Versioning is the mechanism for making change safe: it decides where the version lives in the request, what counts as breaking, and how long old behavior must keep working. All three decisions are engineering, not style choices.

The three mechanisms

1. URL versioning/v1/users, /v2/users. The version is part of the resource identifier:

bash
curl https://api.acme.com/v1/users?page=2

This is the most common mechanism because it is the least demanding: every HTTP client works without changes, the routes are cacheable separately, logs show the version, and routing/load balancing needs no header logic. Its cost: the URL stops being a stable identifier for a resource — /users is now a family of resources, and internal links and bookmarks must be migrated when a version retires. You also pay a naming tax for minor revisions: most teams only bump v1 for major changes, so URL versioning really models "major versions only."

2. Header versioning — the version travels in a request header, either a custom one or the standard Accept:

bash
curl -H "Accept: application/vnd.acme.user+json; version=2" \
     https://api.acme.com/users?page=2

The URL stays stable, which is more honest REST and makes "the resource" a single identity. The costs are real: header values invisible in most access logs, caching infrastructure must key on the Vary: Accept header (get this wrong and you serve v1 representations to v2 clients — cache poisoning), and clients need slightly more machinery than a URL edit.

3. Content negotiation via media types — the vendor media type itself carries the version, as above. It is the most "correct" per HTTP semantics — the client declares what representation it can handle — and the least popular, because tooling support is inconsistent and debugging "why did I get v1?" is harder when the answer lives in an Accept header string.

The honest rule: URL versioning for clients you don't control and can't debug; header or media-type negotiation for clients you do control — SDKs, internal consumers, where you can instrument and migrate them.

What actually counts as breaking

Versioning exists because of a specific failure mode: the client's assumptions no longer match the server's behavior. The common list of what breaks:

text
breaking:
  removed field or renamed field
  changed type of a field (int → string, nullability change)
  new required field in a request body
  changed error shape or status-code semantics
  changed pagination semantics (defaults, ordering, page size)
  tightened validation (a request that used to succeed now fails)
  changed idempotency or caching behavior
 
non-breaking (usually):
  new field in a response          (if clients ignore unknowns)
  new optional field in a request
  new endpoint
  new enum value                   (if clients don't strict-switch)

Note the qualifiers. "Additive" changes break clients that deserialize strictly — generated clients that fail on unknown fields, or UI code that switches exhaustively over enums. Additive is usually safe; it is never guaranteed. If you cannot rule out strict clients, treat every shape change as breaking and version it.

Breaking change policy: the part teams skip

The mechanism is the easy 20%. The policy is the hard 80%, and it must be written down:

  1. A definition of breaking — the list above, adopted by reference in the repo.
  2. A deprecation ritual — announce removal with a deprecation header (Deprecation: true, Sunset: <date>) while the old version still works. Measure who still calls it.
  3. A compatibility window — the minimum overlap period. The pragmatic standard is "support the current major plus one": retire v1 only after v2 has been live for N months or after v1 traffic drops below a threshold you measured, whichever is later.
text
version  status       traffic
v1       deprecated   4%   ← retire after 6 months of &lt;5%, or 12 months, whichever
v2       current      96%     is later — measured, not scheduled
  1. No silent coexistence — parallel versions must not drift into different behavior for the same request. A version is a promise about behavior; test it.

The compatibility window is an operating cost

Supporting v1 and v2 is not free: it is two code paths, two test suites, two monitoring dashboards — a real line item, which is exactly why it belongs in policy and not in vibes. Versioning done well is boring: an explicit mechanism, a written definition of breaking, a measured retirement threshold. Versioning done badly is the API that changed pagination semantics on a Tuesday and learned about it from the incident channel.

The three decisions — where the version lives, what counts as breaking, and how long you must support the old behavior — are the entire subject. Everything else is implementation detail. Choose the mechanism your clients can debug, write down the breaking definition, and let the traffic numbers, not a calendar, retire the old versions.