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
| change | version action |
|---|---|
| new operation, new optional field, new enum value consumers may ignore | compatible; ship it. |
| narrowing a type or removing a field | breaking; new major. |
| new required request field | breaking; new major. |
| fixing a response to match documented shape | compatible (documented behavior wins), noted in changelog. |
| renaming anything | breaking; new major — renaming is never cosmetic across a contract. |
deprecation flow
- publish the new major alongside the old; the generator emits both.
- mark old operations deprecated in the contract with the removal date and the replacement's name.
- migrate every first-party consumer in the same window — the client upgrade is one regeneration, per fern client.
- 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
/v2in 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 contractexport 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 responseconst response = await fetch("/orders/123");const order = response.json(); // { id, dueDate }console.log(order.dueAt); // undefinedconst 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 contractexport interface Order {id: string;dueAt: string;}// rename is a BREAKING change; publish new major v2export 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 mountapp.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.