What counts as a breaking change

A breaking change is any change that can make a correctly written existing client fail or behave incorrectly without that client changing its code. Every versioning mechanism depends on this definition. URL segments, headers and media types only control how a client asks for a contract. They do nothing until the team agrees on what counts as a different contract, and that agreement is why versioning is needed at all.

Change Breaking? Why
Removing or renaming a field Yes Clients reading the old name get nothing or crash
Changing a type or format (string to number, date format) Yes Parsers and deserializers reject or misread the value
Tightening validation Yes Requests that used to succeed are now rejected
Changing a default value Yes Clients that omit the field silently get different behaviour
Changing error shapes or status codes Yes Retry and error-handling logic branches on them
Changing ordering, rounding or other semantics Yes The schema is unchanged, but results differ
Adding an optional field or endpoint Usually not Existing requests and parsing still work

Semantic changes are the most dangerous because schema diffs and contract tests often miss them. A list that used to be sorted by creation time, or a price that used to be rounded half-up, can break downstream logic while every response still validates.

Additive changes are safe only when clients are tolerant readers: they ignore unknown fields and never assume a response has a closed set of keys. A client that uses strict deserialization or exhaustively matches on enum values will break when you add a field or an enum member. You either document tolerance as part of the contract or treat additions as breaking.

// v1 response gains an optional field and a new enum member: an additive change.
const body = '{"id":"ord_123","status":"partially_shipped","total":"19.99","giftWrap":true}';

type Status = "pending" | "shipped" | "cancelled";
interface Order { id: string; status: Status; total: string }

// Strict reader: treats the response as a closed set of keys and enum values.
// It breaks on a change the server considered safe.
function parseStrict(raw: string): Order {
  const data = JSON.parse(raw);
  const allowed = new Set(["id", "status", "total"]);
  for (const key of Object.keys(data)) {
    if (!allowed.has(key)) throw new Error(`unexpected field: ${key}`);
  }
  if (!["pending", "shipped", "cancelled"].includes(data.status)) {
    throw new Error(`unknown status: ${data.status}`);
  }
  return data as Order;
}

// Tolerant reader: picks only the fields it uses, ignores the rest,
// and maps unknown enum values to a fallback instead of failing.
function parseTolerant(raw: string): Order & { status: Status | "unknown" } {
  const data = JSON.parse(raw);
  const known: Status[] = ["pending", "shipped", "cancelled"];
  return {
    id: String(data.id),
    total: String(data.total),
    status: known.includes(data.status) ? data.status : "unknown",
  };
}

try {
  parseStrict(body);
} catch (e) {
  console.log("strict client broke:", (e as Error).message);
}
console.log("tolerant client:", parseTolerant(body));
// Neither reader would notice if "total" switched from half-up to banker's rounding:
// a semantic change passes every shape check, so it still needs a new version.

A strict client fails on an added field and enum member while a tolerant reader keeps working, and neither one can detect a semantic change such as different rounding.

How URL, header and media type versioning work

URL versioning

The version is a path segment, as in curl The gateway routes on the prefix, so /v1/and/v2/can point at different backends with no request inspection. Caches key on the URL by default and need no extra headers. The downside is scope. A path prefix versions the entire API surface, so a breaking change to one endpoint forces a new prefix for every endpoint. It also weakens the idea that a URI identifies one resource, because/v1/orders/42and/v2/orders/42` are the same order under two identifiers.

Header versioning

The client sends a custom header, as in curl -H "API-Version: 2" The gateway reads the header to route the request. The response must include Vary: API-Version`, or a shared cache may serve a v1 body to a v2 client. URLs stay stable, but the version is invisible in browser links, and many access logs never record it unless you configure them to.

Media type versioning

The version goes in the Accept header, either in the subtype (Accept: application/vnd.example.v2+json) or as a parameter (Accept: application/vnd.example+json; version=2). The server echoes the selected type in Content-Type, sets Vary: Accept, and returns 406 Not Acceptable when it cannot produce the requested representation. This approach fits HTTP content negotiation most closely, but it is the hardest for clients to learn, because many HTTP libraries and developers never set Accept deliberately.

# 1) URL versioning: the version is part of the path.
#    The gateway routes /v1/* and /v2/* to different backends without inspecting headers.
curl -i
# HTTP/1.1 200 OK
# Content-Type: application/json
# (no Vary needed: caches already key on the full URL)

# 2) Header versioning: the URL stays stable and a custom header selects the version.
curl -i -H "API-Version: 2"
# HTTP/1.1 200 OK
# Content-Type: application/json
# Vary: API-Version
# (without Vary, a shared cache could serve a cached v1 body to this v2 client)

# 3a) Media type versioning: the version is in the vendor subtype.
curl -i -H "Accept: application/vnd.example.v2+json"
# HTTP/1.1 200 OK
# Content-Type: application/vnd.example.v2+json
# Vary: Accept

# 3b) Media type versioning: the version is a media type parameter.
curl -i -H "Accept: application/vnd.example+json; version=2"
# HTTP/1.1 200 OK
# Content-Type: application/vnd.example+json; version=2
# Vary: Accept

# 3c) The client asks for a representation the server cannot produce.
curl -i -H "Accept: application/vnd.example.v9+json"
# HTTP/1.1 406 Not Acceptable
# Content-Type: application/json
# Vary: Accept
# (the body should list the supported media types so clients can recover)

Curl requests for each versioning style with example response headers, showing that header versioning needs Vary: API-Version and media type versioning needs Vary: Accept and a 406 response for unsupported versions.

Comparing the three approaches on caching, routing and tooling

Axis URL versioning Header versioning Media type versioning
Discoverability Visible in every link and request Hidden unless documented Hidden, and the Accept syntax is unfamiliar to many
Caching and CDN Each version is a distinct cache key Needs Vary on the custom header Needs Vary: Accept
Gateway routing Path prefix match Header match rule Accept parsing
Granularity Usually the whole API Whole API or per resource Per resource
SDK and codegen Paths map directly to specs Header must be injected per request Content types per operation
Access logs Present in default request line Requires logging the header Requires logging Accept
Documentation One spec per version prefix Clear if stated up front Easy to misread

URL versioning is the easiest to operate because every layer already understands paths. A cache keys on the URL by default, a gateway routes on a prefix, and a log line shows the version with no configuration.

The header-based options move the version out of the cache key. If the origin omits Vary, or a CDN ignores it, one client's representation is silently served to another, and nothing errors. Adding Vary fixes correctness but splits each URL into several cache entries. Accept is especially costly to vary on because clients send many different strings for it, so hit rates drop unless the edge normalizes the header first. Per-resource media types also multiply the test matrix: every resource version that a client can mix with other resource versions becomes a combination you must verify.

Choose the mechanism your CDN, gateway, logging and code generators already handle correctly. A theoretically cleaner option that your infrastructure supports only partially will cause more incidents than a plainer one it supports fully.

Who should use which strategy, and when to avoid each

Mapped onto real teams, that rule produces a fairly predictable split.

Strategy Good fit Avoid when
URL path Public APIs with many third-party clients of mixed skill Breaking changes are frequent and each bump forces clients to move every endpoint at once
Custom header Internal APIs where you control the clients and the gateway Your logs, CDN cache keys or support tooling drop or ignore custom headers
Media type Hypermedia-driven or representation-centric APIs Clients depend on code generators that handle vendor media types poorly

URL versioning suits public APIs because the version is visible in every pasted link, browser address bar and support ticket. A developer can change it without learning how to set headers. The cost is granularity. Moving from /v1 to /v2 is all-or-nothing, so a team shipping several breaking changes a year ends up running many parallel trees.

Header versioning keeps URLs stable and lets the gateway route on a value that clients set once in a shared HTTP client. That works when you own the clients. It fails quietly when a cache serves one version's response to another because Vary was never configured.

Media types let a single resource offer several representations through content negotiation. This is the most precise option, and the one generated SDKs most often get wrong.

Teams that rarely break contracts may not need any of these day to day. Additive evolution, meaning new optional fields and new endpoints that never remove or redefine old ones, can carry an API for years. Keep a major-version mechanism in reserve for the rare change that cannot be made additively.

Choosing a default version for unversioned requests

Whatever mechanism you choose, some requests will arrive without a version: curl one-liners, old SDKs, scripts copied from a blog post. The server still has to decide what those requests mean, and there are three options.

Pin to the latest version. This feels friendly, but it makes every major release a breaking change for every client that never specified a version. Those clients are usually the least maintained ones, so they break first and nobody notices until something downstream fails.

Pin to the oldest version. This is safe for existing callers, but the oldest code path can never be retired, because you cannot tell whether a caller depends on it or simply never chose a version. Legacy serializers and validation rules stay in production indefinitely.

Reject the request. A 400 response telling the client to specify a version is the most honest option, and it works well for new APIs. Adding it to an existing API is itself a breaking change.

A common middle ground is to pin per API key or account at first use. When a key makes its first request, record the current version against it and treat that as the key's default from then on. Explicit versions still override it. The middleware resolves the version in a fixed order: explicit request version, then the key's stored pin, then the global fallback. It attaches the result to the request context and logs the resolved version together with how it was resolved. That log field is what later tells you which keys still depend on an implicit default, and how many requests a deprecation would affect.

import type { Request, Response, NextFunction } from "express";

const SUPPORTED = new Set(["2024-01-15", "2025-06-01", "2026-03-10"]);
const CURRENT_VERSION = "2026-03-10"; // what new keys get pinned to
const GLOBAL_FALLBACK = "2024-01-15"; // requests with no key at all

// Placeholder for your key store. pinIfAbsent must be atomic, e.g. INSERT ... ON CONFLICT DO NOTHING.
interface PinStore {
  pinIfAbsent(apiKeyId: string, version: string): Promise<string>; // returns the stored pin
}

export function resolveVersion(store: PinStore) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const explicit = req.get("Api-Version");
    const keyId: string | undefined = res.locals.apiKeyId; // set by auth middleware
    let version: string;
    let source: "explicit" | "key_pin" | "fallback";

    if (explicit) {
      // An explicit version always wins, but only if we still serve it.
      if (!SUPPORTED.has(explicit)) {
        return res.status(400).json({ error: `Unsupported Api-Version: ${explicit}` });
      }
      version = explicit;
      source = "explicit";
    } else if (keyId) {
      // First request from this key records CURRENT_VERSION; later ones read it back.
      version = await store.pinIfAbsent(keyId, CURRENT_VERSION);
      source = "key_pin";
    } else {
      version = GLOBAL_FALLBACK;
      source = "fallback";
    }

    res.locals.apiVersion = version;
    // Log how the version was chosen, not just which one: non-explicit sources are what a deprecation will hit.
    console.log(JSON.stringify({ event: "api_version_resolved", keyId, version, source, path: req.path }));
    next();
  };
}

Express-style middleware that resolves the API version from an explicit header, then the key's first-use pin, then a global fallback, and logs both the resolved version and how it was resolved.

Implementing multiple versions with an edge translation layer

Once the version is resolved, it should affect only the outermost layer of the service. Internally there is one domain model and one set of handlers, written against the current version. Each older version is a pair of adapters at the edge. A request adapter upgrades an old request into the current shape. A response adapter downgrades the current response into the shape that version promised.

Request flow:

client (v1 payload) → version resolver → v1→v2 request adapter → v2→v3 request adapter → current handlers and domain model → v3→v2 response adapter → v2→v1 response adapter → client (v1 payload)

Chain adapters one step at a time, so v1 → v2 → v3, rather than writing a direct v1 → v3 converter. Each new release then needs exactly one new adapter pair, and the oldest supported version stays a short stack of small, independently testable functions. The response adapter in the example below follows this pattern. It renames fields, reshapes nested objects and drops fields that the older contract never exposed, and it does not touch business logic.

This approach has costs:

  • Latency and allocation. Every hop rebuilds the payload. Deep chains on large responses can add measurable overhead, so profile the oldest versions specifically.
  • Debugging. A bug may live in a handler or in any adapter in the chain. Log which adapters ran.
  • Untranslatable semantics. Adapters can handle changes in shape but not changes in meaning. If v3 changed how totals are rounded, or made an operation asynchronous, no adapter can reproduce the old behaviour. In those cases you need a real version branch in the business logic. Keep it explicit, isolated and scheduled for removal.
// Current (v3) shape: the only shape handlers and the domain model produce.
type OrderV3 = {
  id: string;
  customer: { id: string; name: string };
  total: { amountMinor: number; currency: string };
  fulfillment: { status: string; trackingUrl?: string }; // added in v3
};
// Shapes promised by older contracts.
type OrderV2 = { id: string; customerId: string; customerName: string; totalMinor: number; currency: string };
type OrderV1 = { order_id: string; customer_id: string; total_cents: number };

// One step down: v3 -> v2. Flattens nested objects and drops `fulfillment`,
// which the v2 contract never exposed. Shape changes only, no business rules.
const v3ToV2 = (o: OrderV3): OrderV2 => ({
  id: o.id,
  customerId: o.customer.id,
  customerName: o.customer.name,
  totalMinor: o.total.amountMinor,
  currency: o.currency ?? o.total.currency,
});

// One step down: v2 -> v1. Renames to snake_case and drops fields v1 lacked.
// v1 implicitly assumed USD; that is a meaning, so it is enforced upstream, not faked here.
const v2ToV1 = (o: OrderV2): OrderV1 => ({
  order_id: o.id,
  customer_id: o.customerId,
  total_cents: o.totalMinor,
});

// Each release adds exactly one step; older versions reuse the newer steps.
const downgrades = {
  3: (o: OrderV3) => o,
  2: (o: OrderV3) => v3ToV2(o),
  1: (o: OrderV3) => v2ToV1(v3ToV2(o)),
} as const;

export function renderOrder(order: OrderV3, version: 1 | 2 | 3, log: (msg: string) => void) {
  // Record which adapters ran so a bug can be traced to a handler or a specific hop.
  log(`order ${order.id}: downgrade v3 -> v${version}`);
  return downgrades[version](order);
}

A chained response adapter in which the handler always returns the current v3 order and small one-step functions downgrade it to the v2 or v1 shape at the edge.

Deprecation, cost and alternatives to explicit versions

owned by someone, because every branch like that is a version you will eventually have to retire.

Retiring a version

  1. Announce the retirement date.
  2. Signal deprecation in documentation and in response headers such as Deprecation and Sunset.
  3. Measure remaining traffic per version and per client from your logs.
  4. Contact the heaviest remaining consumers directly.
  5. Retire the version with an explicit error, such as 410 Gone and a link to the migration guide, so clients do not fail without explanation.

How long each stage lasts is a policy choice. No standard sets it. In practice, the slowest consumer you cannot afford to break decides when you can retire a version.

What a live version costs

Every live version needs its own regression suite. Security patches have to be backported to it, and on-call engineers have to remember how it behaves.

Illustrative estimate (assumed numbers, not a benchmark): Annual hours = live versions × (testing + patching + on-call upkeep)

  • Assumed: 3 live versions
  • Assumed: 40 hours of testing per version per year
  • Assumed: 20 hours of patching per version per year
  • Assumed: 30 hours of on-call upkeep per version per year

Result: 3 × (40 + 20 + 30) = 270 hours per year.

Thin adapters over one core cut the testing and patching terms, because fixes land once. Duplicated route trees multiply all three terms.

Alternatives to explicit versions

Approach How it works What remains
Additive-only evolution Add fields and mark old ones deprecated Fields accumulate and need eventual removal
Date-based identifiers Clients pin to a release date Every pinned date is still a live version
Schema-driven deprecation (GraphQL style) Mark fields @deprecated and track usage per field The problem moves to the field level but does not go away

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to api versioning strategies, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.