Every API that lives long enough has to change in a way that would break someone. Versioning is how you make that change without breaking the clients who have not moved yet. Most writing on the subject argues about where the version number goes: in the URL, in a header or in the media type. That choice matters less than what happens behind it. The hard part is running several versions at once, from one codebase, for years, without the old versions slowly rotting or multiplying your test matrix.

This article is about that machinery. It covers what a version protects, the main ways to carry it and what each costs, the two operating models of major versions and dated versions, an architecture that serves every version through one set of handlers, working code for a version-change chain, webhooks, testing and telemetry, a worked migration, and the failure modes. Organisational policy, breaking-change detection in CI and the deprecation process are covered in the guide to API design linked at the end.

Advertisement

What a version protects

A version is a promise about the shape and behaviour a client sees. Additive changes keep that promise: a new optional request field, a new response field, a new endpoint. Breaking changes do not: renaming or removing a field, changing a type, making an optional field required, changing a default, narrowing accepted values, or changing the meaning of an existing value. Behaviour counts as much as shape. A list endpoint that changes its default sort order breaks clients that relied on it even though no schema changed.

The goal of a versioning system is to let you make breaking changes for new clients while each existing client keeps exactly the behaviour it integrated against, until it chooses to move. Everything below is a way to make that cheap.

Where the version lives

CarrierExampleStrengthsCosts
URL path/v2/ordersVisible, easy to route at a gateway, trivially cacheableEncourages big-bang versions; links between resources must carry the version
Custom headerApi-Version: 2025-03-01Clean URLs; fine-grained dated versionsInvisible in a browser; caches must Vary on it
Media typeAccept: application/vnd.acme.v2+jsonPer-resource versions; fits content negotiationAwkward tooling, easy to get wrong
Query parameter?api-version=2025-03-01Easy to try by handLeaks into logs and caches as part of the key; easy to drop
Account pinVersion stored on the API key or accountClients get stability without sending anythingBehaviour depends on hidden state; needs good tooling to inspect

Real APIs combine carriers. Stripe pins each account to the version that was current when it first made a request, and lets a request override that with a Stripe-Version header. GitHub's REST API uses a dated X-GitHub-Api-Version header with a default when it is absent. Many internal APIs use a path prefix for rare major versions. Whichever you choose, the server must resolve exactly one version per request, record it, and echo it in the response so a client can see which contract it got.

Advertisement

Major versions or dated versions

There are two operating models, and they lead to different architectures.

Major versions (v1, v2) bundle many breaking changes into rare releases. Each version is often a separate set of handlers, sometimes a separate deployment, routed by path at the gateway. This is simple to reason about, but every bug fix must be applied to every live version, the gap between versions becomes a large migration for clients, and v1 tends to live far longer than planned because nobody can afford the move.

Dated versions (2024-10-01, 2025-03-01) release each breaking change as it is ready, tagged with a date. A client upgrades by moving its date forward and reading the changelog entries in between. Internally there is only one implementation, the current one, and each past breaking change is expressed as a small transform that converts between adjacent versions. The cost moves from maintaining parallel code to maintaining a chain of transforms, which is much smaller, but it needs discipline to build.

A useful rule is to use path versions for rare, total redesigns and dated versions for everything else. If you already have /v1, you can still add dated versions inside it.

Architecture: one implementation, many contracts

ClientApi-Version: 2025-03-01Version resolverheader, pin, defaultAccount storepinned versionRequest transformsold shape to currentCurrent handlersone implementationResponse transformscurrent to old shapeTelemetrycalls per versionWebhook sendersame transformsrequestlookupresolved versioncurrent modelcurrent responseeventsold-shape response + echoed versionHandlers see only the current model; every older version is a chain of small transforms
Serving many API versions from one codebase: resolve the version once at the edge, translate requests up to the current model, run one set of handlers, and translate responses and webhook payloads back down to the caller's version.

The architecture has four parts. A resolver at the edge decides the version once: an explicit header wins, then the account's pinned version, then a default for unauthenticated or new callers. Unknown or malformed versions are rejected with a 400 that lists valid values, never silently mapped to the newest. Request transforms convert an old-shape request into the current model by walking forward through every change newer than the caller's version. Handlers implement only the current contract. Response transforms walk the same changes backwards to turn the current response into the caller's shape.

Because handlers never branch on version, a fix lands once and every version gets it. The version logic is confined to small, testable functions, and deleting an old version means deleting its transforms.

Building the version-change chain

Here is a minimal version-change chain in Python. Each change knows its date, how to upgrade a request from just before it, and how to downgrade a response to just before it.

from dataclasses import dataclass
from typing import Callable

@dataclass(frozen=True)
class VersionChange:
    version: str                      # the version that introduced the change
    description: str
    resources: frozenset              # which resource types it touches
    upgrade_request: Callable[[dict], dict] = lambda r: r
    downgrade_response: Callable[[dict], dict] = lambda r: r

def split_name_up(req):
    if "name" in req:
        given, _, family = req.pop("name").partition(" ")
        req["given_name"], req["family_name"] = given, family
    return req

def split_name_down(resp):
    resp["name"] = (resp.pop("given_name", "") + " " + resp.pop("family_name", "")).strip()
    return resp

def cents_down(resp):
    resp["amount"] = resp.pop("amount_minor") / 100      # old clients saw decimal units
    return resp

CHANGES = [  # oldest first
    VersionChange("2025-03-01", "customer name split into given and family",
                  frozenset({"customer"}), split_name_up, split_name_down),
    VersionChange("2025-09-15", "amount returned in minor units",
                  frozenset({"invoice"}), downgrade_response=cents_down),
]
CURRENT = "2025-09-15"
KNOWN = {"2024-10-01"} | {c.version for c in CHANGES}

def upgrade(req, resource, client_version):
    for ch in CHANGES:                        # forward through newer changes
        if ch.version > client_version and resource in ch.resources:
            req = ch.upgrade_request(req)
    return req

def downgrade(resp, resource, client_version):
    for ch in reversed(CHANGES):              # backward from current
        if ch.version > client_version and resource in ch.resources:
            resp = ch.downgrade_response(resp)
    return resp

ISO dates compare correctly as strings, which keeps the ordering trivial. Each transform touches one concern, so it can be unit-tested in isolation, and the list doubles as the changelog source. Transforms must be pure and must not call services. If an old version needs data the current model no longer holds, that is a sign the change was not really expressible as a transform, and you either keep the data or accept that the old version loses the field and document it.

Version resolution belongs in middleware so no handler can skip it:

def resolve_version(headers, account):
    v = headers.get("Api-Version") or (account.pinned_version if account else None) or CURRENT
    if v not in KNOWN:
        raise BadRequest(f"Unknown Api-Version {v!r}; valid: {sorted(KNOWN)}")
    return v

def handle(request, resource, handler):
    v = resolve_version(request.headers, request.account)
    body = upgrade(dict(request.json or {}), resource, v)
    result = handler(body)                     # current contract only
    out = downgrade(result, resource, v)
    metrics.increment("api.calls", tags={"version": v, "resource": resource})
    return Response(out, headers={"Api-Version": v})

Webhooks and SDKs

Webhooks and other asynchronous payloads are the most common gap. A client pinned to 2025-03-01 that receives an invoice webhook in the current shape breaks just as surely as if the API had changed. Render each event in the current model, then run the same response transforms using the version of the endpoint that receives it, usually the account pin or a version recorded on the webhook endpoint when it was created. Store events in the current shape and transform them on delivery, so a client that upgrades its pin sees retried events in its new shape.

SDKs need the same care. A generated SDK should send the version it was generated against in every request, so upgrading the SDK is an explicit version move rather than an accident of whatever the account pin happens to be.

Testing and telemetry

Testing should scale with the number of changes, not with versions times endpoints. Unit-test each transform in both directions. Then keep golden snapshots: for a fixed set of fixtures, record the full response for each supported version and fail the build if any snapshot changes without an accompanying entry in the change list. That catches the most dangerous bug, which is an accidental change to an old version caused by an edit to the current handler that no transform compensates for.

Telemetry decides when you can retire anything. Count calls per version, per resource and per account, and keep the resolved version in access logs. A version with no traffic for a full business cycle can be scheduled for removal; a version with traffic needs outreach to the specific accounts on it. When you announce retirement, the Sunset response header (RFC 8594) carries the date after which the resource may stop responding, and the Deprecation header (RFC 9745) signals that it is deprecated. Clients and API gateways can log both automatically.

Worked example: amounts in minor units

Suppose an invoicing API must change amounts from decimal units to integer minor units to fix rounding errors. An existing client pinned at 2025-03-01 calls GET /invoices/inv_42.

  1. The resolver finds no header and reads the account pin, 2025-03-01, which is known.
  2. The handler loads the invoice and returns the current model, {"id": "inv_42", "amount_minor": 1999, "currency": "EUR"}.
  3. The downgrade walks backwards. The 2025-09-15 change applies to invoices and is newer than the pin, so cents_down produces {"amount": 19.99}. The 2025-03-01 change is not newer than the pin, so it is skipped.
  4. The response goes out with Api-Version: 2025-03-01, and the call is counted under that version.
  5. A new client sends Api-Version: 2025-09-15 and gets integer minor units directly, and the same handler serves both.

The change shipped without touching the old client, the fix to the rounding bug reached both versions, and the call counts show exactly which accounts still need to migrate.

Failure modes and trade-offs

  • Version branches inside handlers. An if version < X in business logic multiplies over time and makes old versions untestable. Push every difference into transforms.
  • Silent defaulting. Mapping unknown versions to the newest gives typo-ed clients a breaking change. Reject them.
  • Caches that ignore the version. A header-versioned response cached without Vary on the header serves one version's shape to another. Vary on it or include it in the cache key.
  • Unversioned side channels. Webhooks, exports, error bodies and pagination cursors are part of the contract. Version them too.
  • Lossy transforms. A downgrade that cannot represent new data, such as a second currency, needs a documented behaviour, such as omitting the record or returning an error, decided before release.
  • Unbounded support. Every version costs a little. Without telemetry and a retirement policy, the chain grows forever.

The core trade-off is client stability against server complexity. Dated versions with transforms give clients the most stability for the least server code, but require a central place for version logic and strict review. Path versions are simpler to start with and more expensive to sustain. See API design for consistency and longevity for breaking-change detection and policy, deprecation for retiring versions, Stripe's platform for a production example of dated versions, the API gateway pattern for routing at the edge, and REST API design for resource shapes.

What to do next

  1. List every breaking change you have shipped or plan to ship, and decide whether each could be expressed as a pure request or response transform.
  2. Choose a carrier and a resolution order such as header, then pin, then default, and document it. Reject unknown versions with a 400.
  3. Move version resolution into middleware and echo the resolved version in every response.
  4. Refactor one existing if version branch out of a handler into a transform with tests in both directions.
  5. Apply the same transforms to webhook and event payloads, keyed on the receiving endpoint's version.
  6. Add golden snapshots per supported version to CI.
  7. Emit per-version call metrics and review them monthly before scheduling any retirement with Sunset and Deprecation headers.
Key takeaway: The location of the version number matters less than the machinery behind it. Resolve one version per request at the edge, reject unknown values and echo the result. Keep a single current implementation and express each past breaking change as a small, pure transform that upgrades requests and downgrades responses, including webhook payloads. Test each transform and keep golden snapshots per version, count calls per version, and retire versions only when telemetry shows no remaining callers.