api versioning

rule

version the contract document, not URL paths.

  • additive changes (new optional fields, new operations): compatible, ship without ceremony.
  • breaking consumer-shape change (renamed field, removed field, narrowed type, new required input): new major contract version + dated deprecation of the old.

why

a breaking change shipped as "compatible" silently breaks:

  • cached clients
  • old SDKs in the field
  • any consumer that missed the deploy window

the client generator becomes unreliable: regenerating an old version doesn't say it's stale, and a v1-built interface keeps calling v2 routes and fails silently at runtime. a dated major version proves the change is intentional, gives external consumers migration time, and makes the generated client a trustworthy time-capsule — regenerate an old SDK for a specific date and it works.

decision table

changeversion action
new operation, new optional field, new enum value consumers may ignorecompatible; ship it.
narrowing a type or removing a fieldbreaking; new major.
new required request fieldbreaking; new major.
fixing a response to match documented shapecompatible (documented behavior wins), noted in changelog.
renaming anythingbreaking; new major — renaming is never cosmetic across a contract.

deprecation flow

  1. publish the new major alongside the old; the generator emits both.
  2. mark old operations deprecated in the contract with the removal date and the replacement's name.
  3. migrate every first-party consumer in the same window — the client upgrade is one regeneration, per fern client.
  4. after the date, remove the old version in a forward-only change and confirm no consumer regenerates against it.

private product with generated first-party clients: the window can be short, but the sequence is the same — the client generation graph (contract generation graph) is the real consumer list.

do not

  • put /v2 in URLs while the contract document is the version authority; version the OpenAPI document and generated client instead.
  • ship a breaking change as "compatible" because all known consumers were updated in the same deploy — external callers and cached clients exist.
  • keep deprecated operations alive indefinitely.
  • maintain semantic-version ceremony nobody reads; the changelog and deprecation dates carry the information.

verification

  • diff the published contract against its previous published version every release; classify each delta with the decision table.
  • confirm every deprecated operation shows a removal date and a named replacement.
  • after a major removal, regenerate every first-party client and typecheck all consumers — zero references to removed symbols.

good and bad

bad

// API v1 contract
export interface Order {
id: string;
dueAt: string; // ISO date
}
// route ships renamed field, called "fully compatible"
export interface Order {
id: string;
dueDate: string; // ISO date — field renamed
}
// first-party client regenerated: app/*.ts now expect dueDate ✓
// but a cached client built 6 months ago still expects dueAt ✗
// mobile app (last updated 3 months ago) tries to read the response
const response = await fetch("/orders/123");
const order = response.json(); // { id, dueDate }
console.log(order.dueAt); // undefined
const dueDate = new Date(order.dueAt); // NaN — crashes at runtime

renamed field breaks the consumer shape silently: the mobile app (updated before the change) has a cached TypeScript client expecting dueAt, reads the v2 response, gets undefined, and crashes parsing the date.

good

// API v1 contract
export interface Order {
id: string;
dueAt: string;
}
// rename is a BREAKING change; publish new major v2
export interface OrderV2 {
id: string;
dueDate: string;
}
// both versions live in contract for 30 days
// v1 marked deprecated in OpenAPI
// deprecated: true
// x-sunset: "2026-09-25"
// x-replacement-operation: orders.read (v2)
// in `/api` route file, both endpoints mount
app.openapi(ReadOrderV1Route, readOrderV1Handler);
app.openapi(ReadOrderV2Route, readOrderV2Handler);
// first-party clients regenerate: app/*.ts now use v2
// mobile app (built against v1) can regenerate using the historical client
// and continue working until deprecation date passes
// after deprecation date, v1 is removed in a forward-only change
// any client trying to regenerate v1 fails with "contract not found"

breaking change gets a new major. the old version stays available for external consumers and field-deployed clients to migrate on their schedule; a cached client built 6 months ago can regenerate against v1 and keep working.

search pages

go to any page