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

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.