API Versioning & Deprecation

Every breaking change to a live API is a broken promise to somebody’s production system. This reference covers how to evolve an API contract without breaking existing clients — choosing a version identifier, running versions in parallel, signalling deprecation with standard headers, automating the changelog, and pinning generated SDKs — for backend engineers, API architects, and platform teams who own the contract and its downstream consumers.

Architecture Overview

Versioning is a contract concern, not a routing afterthought. The moment an API has one external consumer, its response shape, error semantics, and validation rules become a contract that cannot change silently. A disciplined versioning strategy answers four questions up front, and each maps to a topic in this reference:

The decision that anchors all four is the definition of “breaking.” A change is backward compatible if every request that was valid against the old contract still succeeds, and every response the client could parse before still parses. Additive changes — a new optional field, a new endpoint, a new output enum value the client already tolerates — are safe. Removals, renames, tightened validation, changed defaults, and altered error codes are breaking and require a new major version.

The diagram below shows the lifecycle every major version travels: stable, superseded, deprecated, sunset, removed.

API Version Lifecycle A five-stage timeline: v1 runs as the single stable version; v2 is released and runs in parallel; v1 is marked deprecated with a Deprecation header; v1 is given a Sunset date; v1 is finally removed and returns 410 Gone. API Version Lifecycle Every major version travels this path — signalled in-band at each stage v1 Stable single version v2 Released runs in parallel v1 Deprecated Deprecation: true v1 Sunset Sunset: date set v1 Removed 410 Gone day 0 deprecation notice 90–180 days notice sunset reached Old and new versions coexist for the entire deprecation window — no hard cutovers

Old and new versions coexist for the entire deprecation window. There is no hard cutover — the gateway routes each request to the version its client asked for, and retirement happens only after traffic to the old version has drained. This is why versioning is inseparable from Error Contracts & Resilience Mapping: a client that keeps calling a removed version must receive a structured, actionable error, not a bare 404.

Canonical OpenAPI Spec Definition

The following OpenAPI 3.1 fragment anchors a versioned API. It declares the version in the servers block, documents the deprecation signalling on a legacy operation, and defines the response headers that carry the sunset contract. Each field is annotated with the decision it enforces.

# openapi.yaml — versioned surface (OpenAPI 3.1.0)
openapi: 3.1.0
info:
  title: Orders API
  version: 2.3.0            # semver of the SPEC document, not the URL major version
servers:
  - url: https://api.example.com/v2
    description: Current major version (URL-path versioning)
  - url: https://api.example.com/v1
    description: Deprecated — scheduled for sunset

paths:
  /orders:
    get:
      operationId: listOrders
      deprecated: true       # surfaces in generated SDKs as an @deprecated annotation
      description: >
        Deprecated in favour of GET /v2/orders. This operation emits
        Deprecation and Sunset response headers on every call.
      responses:
        "200":
          description: A page of orders
          headers:
            Deprecation:
              # RFC 8594 — presence (or an IMF-fixdate) marks the resource deprecated
              schema: { type: string, example: "true" }
            Sunset:
              # RFC 8594 — the date after which the resource may return 410 Gone
              schema: { type: string, format: date-time }
              example: "Sat, 31 Oct 2026 23:59:59 GMT"
            Link:
              # rel="deprecation" and rel="successor-version" point clients to docs and the new URL
              schema: { type: string }
              example: '</v2/orders>; rel="successor-version"'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderPage"
        "410":
          description: Version removed after its sunset date
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"

components:
  schemas:
    OrderPage:
      type: object
      required: [data, api_version]
      properties:
        api_version:
          type: string
          const: "2"          # echoes the served major version back to the client
        data:
          type: array
          items: { $ref: "#/components/schemas/Order" }

The deprecated: true flag and the documented Deprecation/Sunset headers are the spec-level source of truth. SDK generators read deprecated to annotate the method; CI reads the servers block and the header schemas to enforce that no version is retired without a sunset contract.

Core Pattern 1: Choosing the Version Identifier

Where the version lives determines how clients select it and how you route it. The three mainstream strategies — covered in depth under URL path vs header versioning and media-type versioning with content negotiation — trade cacheability against granularity.

Strategy Example Best when
URL path GET /v2/orders Default choice — cacheable, greppable, gateway-routable
Custom header X-API-Version: 2 You must keep one stable URL per resource
Media type Accept: application/vnd.example.v2+json You version representations, not the whole surface

Node.js — gateway routing by URL-path major version:

import express from "express";

const app = express();

// Each major version is a mounted router; both run in the same process
// during the deprecation window.
import { v1Router } from "./v1";
import { v2Router } from "./v2";

app.use("/v1", deprecationHeaders("2026-10-31T23:59:59Z"), v1Router);
app.use("/v2", v2Router);

// Middleware that stamps every v1 response with the sunset contract.
function deprecationHeaders(sunsetIso: string) {
  const sunsetHttpDate = new Date(sunsetIso).toUTCString();
  return (_req: express.Request, res: express.Response, next: express.NextFunction) => {
    res.setHeader("Deprecation", "true");
    res.setHeader("Sunset", sunsetHttpDate);
    res.setHeader("Link", '</v2/orders>; rel="successor-version"');
    next();
  };
}

Python — FastAPI sub-application per major version:

from fastapi import FastAPI, Response

app = FastAPI()
v1 = FastAPI()
v2 = FastAPI()

SUNSET = "Sat, 31 Oct 2026 23:59:59 GMT"

@v1.middleware("http")
async def add_deprecation(request, call_next):
    response: Response = await call_next(request)
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = SUNSET
    response.headers["Link"] = '</v2/orders>; rel="successor-version"'
    return response

app.mount("/v1", v1)   # deprecated surface
app.mount("/v2", v2)   # current surface

Mounting one router per major version keeps handler code isolated: a bugfix on v2 cannot regress v1, and deleting the version at sunset is a one-line unmount plus a router removal.

Core Pattern 2: Signalling Deprecation In-Band

A changelog entry that nobody reads does not stop a client from breaking. The reliable channel is the response itself. Sunset and deprecation headers let a client’s HTTP layer detect, log, and alert on deprecation automatically — long before the sunset date arrives. The definitive implementation of the header, its date format, and the 410 Gone follow-through is covered in implementing the Sunset HTTP header.

TypeScript — client interceptor that surfaces deprecation warnings:

// A fetch wrapper that logs and forwards deprecation signals to observability.
export async function versionAwareFetch(url: string, init?: RequestInit): Promise<Response> {
  const res = await fetch(url, init);

  const deprecation = res.headers.get("Deprecation");
  const sunset = res.headers.get("Sunset");
  if (deprecation) {
    const successor = parseLinkRel(res.headers.get("Link"), "successor-version");
    console.warn(
      `[api] ${url} is deprecated` +
        (sunset ? `, sunset ${sunset}` : "") +
        (successor ? `, migrate to ${successor}` : ""),
    );
    // Emit a metric so a dashboard can track deprecated-endpoint call volume.
    metrics.increment("api.deprecated_call", 1, { endpoint: new URL(url).pathname });
  }
  return res;
}

function parseLinkRel(header: string | null, rel: string): string | null {
  if (!header) return null;
  const match = header.split(",").find((part) => part.includes(`rel="${rel}"`));
  return match ? match.slice(match.indexOf("<") + 1, match.indexOf(">")) : null;
}

Turning the Deprecation header into a metric means a platform team can watch deprecated-endpoint traffic fall over the deprecation window and hold the sunset date only until the curve reaches zero — an evidence-based retirement rather than a guess.

Core Pattern 3: Deriving the Changelog from the Spec

Humans forget to document breaking changes; a diff of the OpenAPI document does not. API changelog automation runs a structural comparison between the previous and current spec and classifies every difference as breaking, non-breaking, or informational — the mechanics are detailed in generating API changelogs from OpenAPI diffs.

Bash — fail CI when an unversioned breaking change appears:

#!/usr/bin/env bash
# compare the spec on main against the PR branch and block breaking diffs.
set -euo pipefail

npx @useoptic/optic diff \
  openapi.base.yaml openapi.head.yaml \
  --check --fail-on breaking > changelog.json

# Categorised counts for the PR comment
breaking=$(jq '[.changes[] | select(.severity=="breaking")] | length' changelog.json)
if [ "$breaking" -gt 0 ]; then
  echo "::error::${breaking} breaking change(s) require a major version bump"
  exit 1
fi

Python — categorise a raw diff into a human changelog:

import json
from pathlib import Path

SEVERITY_LABELS = {
    "breaking": "### Breaking changes (major bump required)",
    "addition": "### Added (backward compatible)",
    "info": "### Notes",
}

def render_changelog(diff_path: str) -> str:
    changes = json.loads(Path(diff_path).read_text())["changes"]
    sections: dict[str, list[str]] = {k: [] for k in SEVERITY_LABELS}
    for c in changes:
        bucket = c["severity"] if c["severity"] in sections else "info"
        sections[bucket].append(f"- `{c['location']}` — {c['description']}")
    out = []
    for key, header in SEVERITY_LABELS.items():
        if sections[key]:
            out.append(header + "\n" + "\n".join(sections[key]))
    return "\n\n".join(out)

An automated changelog is only trustworthy if it is generated from the same artifact the SDKs are generated from — the OpenAPI document — so the changelog and the client code can never drift apart.

CI/CD Enforcement

Versioning discipline lives or dies in the pipeline. The workflow below blocks a merge when a breaking change lands without a major version bump, and verifies that every deprecated operation carries a Sunset header.

# .github/workflows/versioning-ci.yml
name: Versioning Contract CI
on:
  pull_request:
    paths: ["openapi.yaml"]

jobs:
  version-guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - name: Diff spec for breaking changes
        run: |
          git show origin/main:openapi.yaml > openapi.base.yaml
          npx @useoptic/optic diff openapi.base.yaml openapi.yaml \
            --check --fail-on breaking

      - name: Assert major version bumped when breaking
        run: node scripts/assert-version-bump.js

      - name: Lint deprecation contract (Spectral)
        run: npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.version.yaml

Spectral ruleset — every deprecated operation must declare a Sunset header:

# .spectral.version.yaml
extends: ["spectral:oas"]
rules:
  deprecated-needs-sunset:
    description: "A deprecated operation must document a Sunset response header"
    given: "$.paths.*[?(@.deprecated == true)].responses.200.headers"
    severity: error
    then:
      field: "Sunset"
      function: truthy

  server-declares-version:
    description: "Each server URL must carry a /vN major-version segment"
    given: "$.servers[*].url"
    severity: error
    then:
      function: pattern
      functionOptions:
        match: "/v[0-9]+$"

These gates align with the linting practices in Contract Linting & Governance: the same Spectral engine that enforces naming and response conventions also enforces the versioning contract.

SDK and Client Impact

A major API version and a major SDK version are two views of the same contract. When the spec’s servers major version changes, the generator emits a new client package, and consumers select it through a pinned semver range — the full mechanics are in SDK version pinning and semver ranges for generated SDK clients.

TypeScript — a generated method carries the deprecated flag through to the IDE:

export class OrdersV1Api {
  /**
   * @deprecated Use OrdersV2Api.listOrders. Sunset 2026-10-31.
   * Generated from `deprecated: true` in the OpenAPI operation.
   */
  async listOrders(query: ListOrdersQuery): Promise<OrderPage> {
    return this.http.get("/v1/orders", { query });
  }
}

Go — build-time selection of the pinned major version:

// go.mod pins the generated SDK to a compatible major version.
// require github.com/example/orders-go/v2 v2.3.0
import orders "github.com/example/orders-go/v2"

client := orders.NewClient("https://api.example.com/v2")
page, err := client.ListOrders(ctx, orders.ListOrdersParams{Limit: 20})

Because Go encodes the major version in the import path (/v2), a client cannot accidentally compile against two incompatible majors at once — the versioning contract is enforced by the toolchain. In npm and PyPI the same guarantee comes from a caret range plus a lockfile.

Edge Cases and Anti-Patterns

Anti-pattern Recommended approach
Shipping a breaking change under the same version Bump the major version; run both in parallel behind the gateway
Removing a version with no notice Emit Deprecation and Sunset well ahead; return 410 Gone only after the sunset date
Versioning every additive change (/v2, /v3, /v4 in a month) Add optional fields and endpoints in place; reserve majors for breaking changes
Sunset as a bare date with no timezone Use an RFC 7231 IMF-fixdate (GMT) so all clients agree on the instant
Version signalled only in docs Signal in-band via headers or URL so client HTTP layers can react automatically
SDK version decoupled from API version Map each API major to an SDK major; pin with a caret range and a lockfile
Deleting old handler code at cutover Isolate versions in separate routers so retirement is a clean removal
Announcing breaking changes by memory Generate the changelog from the OpenAPI diff in CI

Knowing a change is breaking before you ship it

Everything above assumes the hard question is already answered: is this change breaking? In practice that question is where most incidents originate, because almost no breaking change is proposed as one. Somebody renames a field for clarity, marks a parameter required to tighten validation, or removes an enum value that “nothing uses” — each looks local, and the consumer that breaks is three teams away.

Breaking Change Detection turns that judgement call into a mechanical check. Every pull request is diffed against the specification as released — not against the main branch, which answers a different question — and each change is classified against one asymmetry: clients send requests and receive responses, so accepting more is safe while promising less is not. A required field added to a request is breaking; the same field added to a response is not. A field removed from a response is breaking; removed from a request it is merely ignored.

The classification also has a downstream job. Feeding it into release automation is what makes a consumer’s semver range mean something: a major SDK bump then happens whenever, and only whenever, the client surface actually changed. Deriving that mechanically rather than by author judgement is the difference between a version number that carries a guarantee and one that carries an intention.

What a diff cannot see is behaviour. A reversed default sort order, a lowered page-size cap, a new validation rule enforced in code but never written into the schema, an error type URI reworded — all break consumers while the diff reports nothing, which is why detection needs a behavioural test suite alongside it.

The asymmetry that classifies a change For requests, accepting more is safe and accepting less is breaking. For responses, promising more is safe and promising less is breaking. Examples are listed under each direction. requests — what you accept responses — what you promise SAFE — accept more • required → optional • widen a range • add an accepted enum value • add an endpoint BREAKING — accept less • optional → required • narrow a range • remove an enum value • tighten maxLength SAFE — promise more • add a field • optional → always present • narrow a returned value range BREAKING — promise less • remove or rename a field • guaranteed → optional • add an enum value clients must handle

The lifecycle, end to end

Read together, the sections here describe one loop that runs on every change.

A change is proposed and classified — additive or breaking — by diffing the merged specification against the released baseline. If it is breaking, it either becomes a new version identifier under URL Path vs Header Versioning, or it is staged: shipped optional first, warned about in-band, measured per caller, and enforced on an announced date. If it is additive, it ships immediately and appears in the generated release notes.

Either way the change reaches consumers through two channels that must agree. The machine channel is in-band — Deprecation and Sunset headers on affected responses, described in Sunset & Deprecation Headers — so a running integration learns about the retirement from the traffic it is already sending. The human channel is the changelog generated in API Changelog Automation, derived from the same diff so the two cannot drift apart.

Finally the change lands in a published client. SDK Version Pinning covers the consumer side of that: a semver range that expresses intent, a lockfile that records what was actually installed, and a dependency bot configured so a major bump — which by construction means the client surface changed — reaches a human rather than an auto-merge.

Choosing a deprecation window

Every retirement in this reference depends on one number nobody can derive from a specification: how long consumers get. Choosing it badly in either direction is expensive — too short and integrations break, too long and you maintain two code paths for a year while the traffic to the old one never quite reaches zero.

The honest method is to measure rather than legislate. Instrument which callers still use the old path, and watch the adoption curve after you announce. It rises quickly at first as attentive teams migrate, then flattens; the flat part is the signal that the remaining callers will not move without a deadline, and that is when a firm date plus direct contact does more than another month of waiting. For internal services deployed weekly, that whole arc often takes three or four weeks. For third-party integrations with quarterly release trains it is months, and for embedded or on-premises clients a year is not unusual.

Whatever the window, the two failure modes to avoid are silence and drift. Silence means the only announcement lived in a changelog nobody subscribed to, which is why the in-band Deprecation and Sunset headers matter: they reach the machine that is actually calling you. Drift means the date moved twice, at which point consumers correctly conclude that the next date will move too, and the deadline stops functioning. Announce once, put the date in the response headers and the specification description, and hold it.

The loop closes when both channels — machine and human — carry the same dates, derived from the same source:

The two announcement channels and what each reaches In-band headers reach the running integration that is already sending traffic. The changelog reaches the humans who will schedule the migration. Both must carry identical dates. in-band headers generated changelog Deprecation and Sunset on responses reaches the running job successor-version Link included no subscription required derived from the same spec diff reaches the humans who plan grouped by severity published with each release a date that appears in one channel and not the other is the date nobody honours

FAQ

Should a new API version live in the URL path or in a header?

Use URL-path versioning (/v2/) as the default: it is cacheable, greppable in logs, and trivial to route at the gateway. Reserve header or media-type negotiation for APIs that must keep a single stable URL per resource, or that version individual representations rather than the whole surface.

When does a change require a new major API version?

Bump the major version only for breaking changes: removing or renaming a field, tightening a type or validation rule, changing a default, or altering error semantics. Additive changes — new optional fields, new endpoints, new output enum values — are backward compatible and ship without a version bump.

How long should a deprecated version stay available before sunset?

Announce deprecation with a Deprecation header and a Sunset date at least one to two release cycles ahead — commonly 90 to 180 days for public APIs. Track active clients by SDK version and hold the sunset until traffic falls below an agreed threshold.

How do versioning decisions affect generated SDK clients?

Each major API version maps to a generated SDK whose package version is pinned with a semver range. Breaking spec changes force a major SDK bump; additive changes ship as minor releases. Consumers pin a caret range so they receive compatible fixes without silently adopting a breaking version.