On this page
API versioning is a promise about what existing clients can continue doing. A version number helps identify a contract, but it does not define compatibility, migrate consumers, or stop accidental behavior changes. Those responsibilities need an explicit policy backed by tests and ownership.
Define the contract before choosing a version label
Start with a real response and use the JSON to TypeScript tool to sketch its client-facing shape. Compare generated types for today's response and a proposed replacement. This makes renamed fields and changed nesting visible, although generated interfaces are not runtime validation or a complete API specification.
A public contract includes more than property names. Clients depend on status codes, error bodies, pagination rules, authorization scopes, sorting, nullability, and sometimes undocumented behavior. Document the intended guarantees before deciding whether a change belongs in the current version.
For example, replacing a decimal amount with an integer count of minor currency units is breaking even if both values are JSON numbers. Changing a timestamp from UTC to local time can be equally damaging. Types describe structure; examples and prose must describe meaning.
Keep a small inventory of consumers, supported versions, and owners. An internal dashboard controlled by one team has different migration options from a mobile application whose older releases remain installed for years. Versioning policy should follow that reality rather than a fashionable URL convention.
Use URL versions when visibility matters most
A path such as /v1/orders makes the selected contract obvious in logs, documentation, and copied requests. URL versioning is easy to route and convenient when gateways or client tooling provide limited header support. Different major versions also naturally occupy different cache keys.
The cost is additional public surface. Documentation, authorization rules, rate limits, and tests must remain consistent across paths. Avoid implementing every version as a separate copy of the entire application. Prefer shared domain operations with version-specific adapters at the boundary.
Reserve a major path version for changes that require clients to adapt. Adding /v2 for every optional response field creates migrations without meaningful benefit. Conversely, keeping /v1 forever while changing existing meanings merely hides breaking changes behind a stable address.
An explicit routing sketch can reject unknown versions rather than silently substituting the newest contract:
type Version = "v1" | "v2";
function selectVersion(segment: string): Version | null {
return segment === "v1" || segment === "v2" ? segment : null;
}
function presentOrder(order: { id: string; totalMinor: number }, version: Version) {
if (version === "v1") {
return { id: order.id, total: order.totalMinor / 100 };
}
return { id: order.id, totalMinor: order.totalMinor, currency: "USD" };
}This example assumes a USD-only domain and uses simplified conversion for illustration. Real monetary contracts must define precision and currency explicitly. The adapter's purpose is to preserve the older representation while the internal model evolves independently.
Use headers when representation selection is deliberate
Header versioning keeps resource URLs stable while selecting a contract through a request header or media type. An API might use a documented custom header or an Accept value such as application/vnd.example.orders.v2+json. Pick one mechanism and explain it in every runnable example.
Negotiation creates operational obligations. Shared caches must distinguish representations, commonly through Vary: Accept or the custom version header. Review the MDN Vary documentation and verify that your gateway actually incorporates the selected header into its cache behavior.
Browser clients may require CORS preflights when sending non-safelisted headers, and observability tools need to record the effective version. Custom headers can be overlooked when developers reproduce a request by pasting only its URL. Include version selection in generated SDKs and debugging templates.
Specify behavior when the header is absent or unsupported. A stable default can help existing clients, but automatically selecting the newest version risks surprising them. Return a documented error for unsupported values; the appropriate status depends on whether you use media-type negotiation or a custom contract selector. See HTTP content negotiation for the underlying distinction.
Evolve without versions through compatibility rules
Compatible evolution works when changes preserve existing clients' documented expectations. Adding an optional request property is often safe. Adding a response field can be safe when consumers tolerate unknown properties, but strict deserializers may reject it. Test the actual consumer behavior rather than assuming all JSON clients are permissive.
Adding an enum value deserves special scrutiny. A client may have an exhaustive switch with no fallback. Likewise, changing default ordering can alter pagination outcomes without changing the schema. Expanding accepted input is generally safer than tightening validation, but both still require a security and business-rule review.
Publish a compatibility checklist covering required fields, nullable values, numeric ranges, errors, and defaults. Treat semantic changes as first-class review items. The Google API compatibility guidance offers a useful reference for reasoning about changes beyond syntax.
Maintain consumer fixtures and contract tests for each supported version. Use a JSON formatter when reviewing representative payloads, then automate comparisons in your test suite. Formatting helps humans inspect examples; only executable checks can guard every release reliably.
Make deprecation an observable migration
A retirement plan needs an owner, a replacement contract, a migration guide, and a realistic window based on client release cycles. Announce the exact changes with before-and-after requests. Give consumers a staging endpoint or a controlled way to test the new representation against representative data.
Measure version adoption by application identity rather than collecting unnecessary personal data. Track request counts, failures, and the last observed use of retiring contracts. Absence of traffic during one short period is not proof that a monthly integration has migrated.
Keep database changes backward compatible while old and new handlers coexist. Expand storage first, backfill deliberately, migrate readers and writers, then remove obsolete fields after retirement. A rollback should not require reconstructing data that the new version already discarded.
For additional implementation context, keep the migration policy with your backend API documentation. Decide retirement criteria before announcing a deadline, and define an exception process for critical consumers. A clear, enforceable support policy is more valuable than accumulating permanent versions that nobody can safely remove.
Key takeaways
- Define compatibility in terms of behavior and meaning, not just JSON shape.
- Choose URL versions for visibility and header versions for deliberate representation negotiation.
- Make caches, logs, SDKs, and defaults aware of version selection.
- Test additive changes against real consumer assumptions.
- Retire versions through measured adoption and a documented migration process.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor