API Design

Reliability & Scale · Lesson 01

API Versioning

Change is inevitable — clients are not. Versioning is the mechanism that lets a provider evolve a contract while the callers who depend on the old one keep running undisturbed.

⏱ 12 min Difficulty: core Prereq: HTTP basics, REST resources

By the end you'll be able to

Why versioning exists at all

Think of a published dictionary. Once a word is in print, every reader who owns a copy depends on that definition. If the publisher changes "literally" to mean "figuratively" mid-edition without issuing a new volume, every existing owner is reading a different book than they think. Software contracts have the same problem — except the readers are machines that never tolerate inconsistency.

An API version is the edition number on the dictionary spine. It signals: "this is the promise I made in October 2022; that is the promise I make today." Clients pin to a specific edition so they know exactly what they're getting.

Breaking vs additive changes

Not all changes are equal. The key distinction:

CategorySafe for existing clients?Examples
Additive Yes — clients ignore what they don't understand New optional request field, new response field, new endpoint, new enum value (if unknown values are ignored)
Breaking No — existing clients can crash or behave incorrectly Removing/renaming a field, changing a field's type, making an optional field required, altering error codes, removing an endpoint, changing authentication

The engineering rule of thumb: you can add, but you must not remove or change the shape of anything already published. If you must do so, you need a new version.

✅ Do this, not that

Do add new response fields freely — well-written clients skip unknowns. Don't reuse an existing field name for different semantics. If amount was dollars and you need it to mean cents, create amount_cents; never silently redefine amount. The worst breakages in production history come from semantics quietly drifting under an unchanged name.

Three versioning strategies

1. URI path versioning

The version lives in the URL itself: /v1/users, /v2/users.

GET /v1/users/42          # old clients keep using this forever
GET /v2/users/42          # new clients opt in here

Pros: The version is immediately visible in logs, browser history, and documentation URLs. Easy to route in a reverse proxy. Developers can paste a link and be unambiguous about which version they mean.

Cons: Technically violates REST purity — the same resource now has two different stable URLs, which can confuse caches and links. Encourages large version increments ("v2 is totally new!") rather than fine-grained changes.

2. Custom request-header versioning

The version travels in a custom header rather than the path.

GET /users/42 HTTP/1.1
Host: api.example.com
Api-Version: 2024-03-01   # date-based version — Stripe's approach

Pros: The URL is "clean" and permanent — bookmarks and caches reference a single endpoint. Works well with date-stamped versioning (Stripe uses this), which makes deprecation timelines obvious.

Cons: Invisible in a URL. Curl newcomers forget to add it. Requires API gateways to forward or interpret the header. Harder to test in a plain browser bar.

3. Media-type (Accept) versioning

The client signals its desired version in the HTTP Accept header using a vendor MIME type.

GET /users/42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.myapp.v2+json

Pros: Maximally RESTful — a single canonical URL for each resource; version is part of content negotiation. Follows the design intent of HTTP's Accept header.

Cons: Highest cognitive overhead. Very few developers know to look for it. Server-side content negotiation machinery is nontrivial to implement and test. Rarely used outside high-ceremony APIs.

Comparison at a glance

StrategyVisible in URLREST purityAdoption frictionTypical user
URI pathYesLowMinimalMost public APIs
Custom headerNoMediumLow–mediumStripe, internal APIs
Media-typeNoHighHighGitHub v3 (partial)
Old Client GET /v1/users/42 New Client GET /v2/users/42 API Gateway routes by version prefix or header v1 Handler legacy logic v2 Handler new logic Shared Data Store
v1 and v2 coexist behind the same gateway. The gateway routes by version prefix (or header); each version has its own handler but can share the underlying data store via an adapter layer.

Backward compatibility and the "Robustness Principle"

Jon Postel's old networking maxim — be conservative in what you send, liberal in what you accept — maps naturally to versioning. On the server side: never remove a field without a deprecation window, never change a field's type silently. On the client side: ignore unknown keys, tolerate new enum values you haven't seen before. If both parties hold to this, additive changes never break anything and a new version is only needed for genuinely incompatible changes.

Deprecation policy and the Sunset header

A version no one can ever remove is a support liability. A clear deprecation policy lets teams plan migrations. The minimum viable policy has four components:

  1. Announce the planned end-of-life date at least 6–12 months in advance (longer for well-adopted public APIs).
  2. Signal in-band via the Sunset HTTP response header (RFC 8594) so monitoring tools can catch it automatically.
  3. Email/notify registered integrators so humans are also in the loop.
  4. Remove only after the announced date; return 410 Gone with a pointer to the current version.
# Response from a deprecated v1 endpoint
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 31 Dec 2024 23:59:59 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"

{ "id": 42, "name": "Ada Lovelace" }

The Sunset header value is an HTTP-date marking when the endpoint will stop responding. Monitoring tools and SDKs can parse this header and surface a warning dashboard entry automatically — no human polling required.

🎯 Interview angle

A common system-design question: "How would you version this API?" Start by asking about the audience (internal vs external), change cadence, and whether clients control their own upgrade cycle. Then name the strategy you'd pick and why — the trade-off reasoning matters more than the choice itself. Bonus points: mention the Sunset header as the in-band deprecation signal and describe a migration window. Saying "I'd just increment the path prefix" without justification reads as junior; showing you considered header vs URI vs media-type and landed somewhere deliberately reads as senior.

⚠️ Common trap

Shipping a breaking change under an existing version number. This happens most often when a field is "lightly renamed" or a response restructured and the change is considered too small to warrant a version bump. The effect: every client that calls the old endpoint silently starts receiving different data. In the best case it surfaces immediately as a 400 or parse error; in the worst case it manifests as corrupted data written to a database weeks later. Any change that can break a client must carry a new version number, no matter how small it feels.

Under the hood: how versioning is actually dispatched

When a request arrives, something has to read the version signal and route it to the right handler. The mechanism differs by strategy — and understanding it makes clear why misconfiguration produces silent breakage rather than obvious errors.

URI path routing

The gateway or web framework simply prefix-matches the URL path. In nginx this is a location block; in Express it is a router mount; in AWS API Gateway it is a stage or base-path mapping. The rule is literal string matching — there is no version inference or fallback.

# nginx — two version prefixes proxied to different upstream groups
location /v1/ {
    proxy_pass http://api_v1_upstream/;   # v1 pod pool
}
location /v2/ {
    proxy_pass http://api_v2_upstream/;   # v2 pod pool
}
# A request for /v3/users gets a 404 — there is no location block for it.
# A request for /users (no prefix) also gets a 404 unless you add a default.

The v1 and v2 upstream groups can be separate deployments, or the same application code with a version environment variable controlling which handler module loads. Shared code typically lives behind a shared data access layer that both versions call — each handler transforms the request/response shape, but the database logic is common.

Custom-header routing

The gateway must inspect a non-standard header before routing. In Envoy or Kong this is a header-match rule; in application middleware it is a guard that reads the header early in the pipeline.

# Express middleware — read Api-Version header, attach to req
app.use((req, res, next) => {
  req.apiVersion = req.headers['api-version'] || '2024-01-01';  // default to oldest stable
  next();
});

# Route handler chooses behaviour based on the attached version
app.get('/users/:id', (req, res) => {
  if (req.apiVersion >= '2025-03-01') {
    return serveV2User(req, res);  // new response shape
  }
  serveV1User(req, res);          // legacy shape
});

A common gotcha: if no default version is set and the header is absent, the server either 400s or silently uses the latest — both can surprise callers. Be explicit about the default.

Content negotiation (Accept header)

The server parses the Accept header using standard HTTP content negotiation. The client sends a vendor MIME type; the server looks for a handler registered under that type and returns a matching Content-Type in the response.

# Request
GET /users/42 HTTP/1.1
Accept: application/vnd.myapp.v2+json, application/json;q=0.8

# Server selects the highest-preference type it supports
# If it supports v2: 
HTTP/1.1 200 OK
Content-Type: application/vnd.myapp.v2+json

# If v2 is not implemented, falls back to application/json (q=0.8)
HTTP/1.1 200 OK
Content-Type: application/json    # ← v1 shape, silently

That silent fallback is the main danger: a client that requests v2 can receive v1 data without a clear error if the server does not strictly enforce the negotiation. The server should return 406 Not Acceptable if it cannot serve the requested version type at all.

How two versions share code

The pattern used by Stripe, GitHub, and most mature APIs is a transformation layer: the canonical data model and business logic live in one place; version adapters sit at the request/response boundary and translate between the current shape and each supported version's shape. When v1 is sunset, you delete one adapter file — the core logic is untouched.

# Shared handler pattern
def get_user(user_id):
    user = db.get_user(user_id)       # one data access call
    return user                         # canonical model object

# Version adapters
def serialize_v1(user): return {"id": user.id, "amount": user.fee_usd}
def serialize_v2(user): return {"id": user.id, "fee_minor_units": user.fee_cents}

How to debug & inspect it

The most common versioning bug is a client receiving the wrong version's response without realising it — either because the version signal was silently ignored or because the server defaulted differently than expected. Three tools cover the diagnostic space: curl -I, DevTools Network, and the headers themselves.

Confirm which version was actually served

$ curl -sI https://api.example.com/v1/users/42 HTTP/2 200 content-type: application/json api-version: 2023-01-01 # echo back the version that handled this request deprecation: true sunset: Sat, 31 Dec 2025 23:59:59 GMT link: <https://api.example.com/v2/users>; rel="successor-version" # Compare: header versioning — send the version in the request $ curl -sI https://api.example.com/users/42 \ -H "Api-Version: 2025-03-01" content-type: application/json api-version: 2025-03-01 # server echoes the version it served # Forgot the header — what did you get? $ curl -sI https://api.example.com/users/42 api-version: 2023-01-01 # server fell back to default — was this what you expected?

Read Deprecation and Sunset headers programmatically

$ curl -sI https://api.example.com/v1/users \ | grep -Ei "^(deprecation|sunset|link)" deprecation: true sunset: Sat, 31 Dec 2025 23:59:59 GMT link: <https://api.example.com/v2/users>; rel="successor-version" # Alert: if a monitoring script parses the sunset date and # compares it to today, it can auto-create a JIRA ticket to migrate.

Symptom → cause → fix

SymptomLikely causeFix
Client gets unexpected field shape after a deployBreaking change shipped under the existing version number — no version bumpRoll back; bump to v2; audit change against the breaking-change checklist
Calls to /v2/users return 404Gateway has no route for /v2 — deployment incomplete or routing config not updatedCheck the gateway route table; redeploy or add the location/stage mapping
Header versioning: all requests get v1 response even when Api-Version: 2025-03-01 is sentMiddleware reads the wrong header name (case-sensitivity, typo), or gateway strips custom headers before forwardingcurl -v and inspect the actual headers the server received; check gateway header-forwarding config
Client gets a v1 response when it sent no version header and expected the latestServer default version is pinned to an old releaseClarify your API's default-version policy; update docs; consider returning 400 when the header is absent to force explicit opt-in
v1 endpoint still responds after the Sunset dateGateway routing was not updated; shutdown was not implementedAdd a gateway rule to return 410 Gone with a migration pointer for all v1 paths after the sunset date
Client that handles Deprecation: true suddenly starts alerting after working fineServer added deprecation headers to a new endpoint the client recently started callingCheck the Sunset date; initiate migration — this is the warning system working as intended
  1. After any deploy, curl -I a versioned endpoint and confirm the echoed version matches what you deployed.
  2. In DevTools, check the api-version (or equivalent) response header on every call to catch silent rollbacks or misrouting.
  3. Monitor for Deprecation: true and Sunset headers in your integration test suite or API client wrapper — surface them as CI warnings before they become production surprises.
  4. When a "client broke after a change" ticket arrives, first confirm which version it was calling (check request logs for the version prefix or header), then diff that version's response schema against what the client expected.
  5. If a header version is going missing in transit, run curl -v and compare request headers sent vs. received at the server side (via access logs with header capture).

In production: how leading APIs do it

Versioning decisions made at a platform's founding tend to outlast every other architectural choice — they shape client SDKs, third-party integrations, and support burden for years. Examining how five platforms solved the problem reveals that the stakes are high enough that each arrived at a different answer, and each answer reflects specific operational priorities.

Company Scheme How the version is selected
Stripe Date-based (e.g. 2024-06-20); each account is permanently pinned to the version that was current at signup Default: the account's pinned version; override per-request via the Stripe-Version header. Additive changes ship freely to all versions; breaking changes only appear under a newer date-version. Accounts never see a breaking change unless they explicitly upgrade their pinned version in the Dashboard.
GitHub Date-based (e.g. 2022-11-28) Passed via the X-GitHub-Api-Version request header. If absent, GitHub defaults to the pre-versioning behaviour. Callers that specify a date get a stable contract for that date's schema.
AWS Dated service versions per operation (e.g. EC2 Version=2016-11-15); each AWS service is versioned independently Passed as a query-string parameter (?Version=2016-11-15) in the request. The version identifies which WSDL/API shape the caller expects; AWS maintains multiple live versions simultaneously for backwards compatibility.
HubSpot Integer URL path version (e.g. /crm/v3) Encoded directly in the URL path; routing is done at the gateway level by prefix. Each major version is a discrete set of endpoints; callers migrate explicitly by updating URLs.
Azure Date-based (e.g. 2023-07-01) Required api-version query parameter on every request (e.g. ?api-version=2023-07-01). Requests without the parameter are rejected with a 400; there is no default version, which forces callers to be explicit.

Deep-dive: Stripe's account-pinning model

Most versioning schemes require clients to specify which version they want. Stripe inverts this: when a developer creates an account, the platform records the API version that was current on that day, and all subsequent requests from that account use that version by default — permanently. The client never has to remember to send a version header to stay stable, because the server remembers it for them.

The operational consequence is profound. When Stripe ships a breaking change, it publishes it under a new date-version. Existing accounts see nothing different — their pinned version still routes to the old behaviour. The developer who wants the new feature upgrades their pin deliberately in the Dashboard, reads the migration guide, and updates their integration code before flipping the switch. There is no accidental breakage, no emergency rollback, and no coordinated cutover.

Additive changes — new response fields, new optional request parameters, new endpoints — ship to all versions simultaneously. This keeps the number of parallel code paths manageable: the codebase only forks on genuinely incompatible changes, not on every incremental addition. The result is that Stripe maintains a relatively small number of distinct schemas despite a long history of changes, because the date-version tag only creates a new fork when truly necessary.

This model is widely considered the gold standard for "evolve without breaking anyone" because it shifts the upgrade decision entirely to the consumer and makes the default safe. The cost is operational complexity on the provider side: every breaking change requires a version transformer, and old versions must stay alive indefinitely until the last pinned account upgrades. Stripe handles this by tracking, in real time, how many accounts are still on each version — and only retiring a version once the population drops to zero.

How leading APIs do it

🧠 Quick check

1. Which of the following is a breaking change that requires a new API version?

Renaming a field removes what the client was reading — any client looking for amount now gets undefined. That's a breaking change. Adding an optional field or a new endpoint are additive and safe.

2. URI path versioning's main practical advantage over custom-header versioning is:

URI versioning's biggest win is operational visibility — anyone reading a request log or sharing a URL immediately sees which version is in use. Custom-header versions are invisible unless you specifically look for them. REST purity actually favors the header approach, not URI versioning.

3. The HTTP Sunset header communicates:

RFC 8594 defines the Sunset header as a signal to clients that the resource will become unresponsive after the stated date — a machine-readable deprecation deadline.

4. You discover that a colleague has added a required field country_code to an existing POST endpoint body in v1 without bumping the version. What is the immediate risk?

Making a previously-optional or absent field required is a classic breaking change. Every existing client request is now malformed from the server's perspective, producing validation failures. Loud failures are better than silent data corruption, but either way the contract has been violated without consent from callers.

✍️ Exercise: sketch a versioning strategy for a payment API (try before opening)

A fintech startup's REST API is at v1. The team needs to change the POST /v1/payments response body: the field fee (a dollar float like 1.50) will be replaced by fee_minor_units (an integer of cents like 150). There are 200 existing integrations in production. Outline a versioning and migration plan.

Model answer:

  1. Do not modify v1. The existing 200 clients depend on fee as a float. Changing it under them risks silent financial calculation errors.
  2. Create v2 — either as /v2/payments (URI strategy) or with a new date-based header value. In v2 the response contains fee_minor_units (integer) and the old fee field is absent.
  3. Announce deprecation of v1 with an end-of-life date 9 months out. Add the Sunset header to all v1 responses pointing to that date, and a Link header pointing to the v2 documentation.
  4. Provide a migration guide showing the exact diff: fee: 1.50fee_minor_units: 150, with example code in the top 3 client languages.
  5. After the Sunset date, return 410 Gone from v1 endpoints with a body pointing to v2.

Rubric: ✓ v1 left intact ✓ v2 created with new field name ✓ Sunset header mentioned ✓ migration window defined (not just "when ready") ✓ 410 for removed endpoints. Four out of five = strong answer; all five = excellent.

Key takeaways

Sources & further reading