Guide

API versioning explained

The first version of your API ships with assumptions you will later regret — field names that no longer match the domain, pagination cursors that leak internal IDs, error shapes that mobile clients parse with brittle regex. API versioning is how you evolve the contract without silently breaking every integrator on deploy day. This guide compares URL path, header, and content-negotiation strategies; defines what counts as a breaking change; covers deprecation timelines and sunset headers; and ends with a production checklist tied to REST API design, API gateways, and idempotent retries.

Why versioning is a product decision, not a URL trick

An API is a promise. When a fintech startup hard-codes your amount_cents field into their ledger, renaming it to amount_minor_units is not a "small refactor" — it is a production incident waiting for a Friday deploy. Versioning creates a boundary: clients on v1 keep working while v2 introduces the better model. The cost is operational: you maintain two code paths, two sets of docs, and two bug surfaces until the old version retires.

The goal is not to version everything. Mature teams version sparingly and prefer additive changes — new optional fields, new endpoints, new query parameters — that old clients ignore. Reserve a major version bump for changes that would otherwise force every consumer to change code on your schedule. Document that policy publicly; integrators plan migrations in quarters, not sprints.

Breaking vs non-breaking changes

A non-breaking change is safe for existing clients: adding an optional JSON field, adding a new endpoint, widening an enum with a new value (if clients treat unknown values gracefully), or returning additional HTTP headers. A breaking change alters behavior clients depend on: removing or renaming fields, changing field types, tightening validation, altering default pagination size, changing error status codes, or removing endpoints.

  • Changing created_at from ISO-8601 string to Unix epoch — breaking.
  • Adding updated_at alongside created_at — non-breaking.
  • Replacing offset pagination with cursor-only — breaking for clients that page by number.
  • Adding cursor pagination while keeping offset — non-breaking if documented as legacy.

When in doubt, treat a change as breaking. Shipping a silent break destroys trust faster than maintaining an extra version for six months.

URL path versioning

The most visible pattern: prefix every route with a major version. GET /v1/users/42 and GET /v2/users/42 may return different JSON shapes. Stripe, Twilio, and GitHub expose versions this way (GitHub also uses dated media types — more on headers below). Path versioning is easy for humans: copy the URL from docs, paste into curl, see the version in server logs.

Downsides are real. URLs proliferate in bookmarks, webhook configs, and mobile app binaries. An API gateway can route /v1/* and /v2/* to different upstream services, but you still duplicate route tables or maintain version-aware handlers. Some teams embed only the major version in the path and handle minor additive changes without a new prefix — v1 for five years, v2 when the payment model fundamentally changes.

When path versioning wins

  • Public REST APIs with many third-party integrators who expect obvious version markers.
  • Microservices where v1 and v2 genuinely run different binaries or databases.
  • SDK generators that bake the base URL including version into client libraries.

Header and content negotiation versioning

Instead of polluting URLs, clients send the desired version in a header. Common patterns:

  • Custom header: Api-Version: 2024-10-01 or X-API-Version: 2
  • Accept media type: Accept: application/vnd.myapi.v2+json
  • GitHub-style: Accept: application/vnd.github+json plus X-GitHub-Api-Version: 2022-11-28

Header versioning keeps URLs stable — /users/42 is always the canonical resource locator — which helps caching layers and SEO-facing public docs. The trade-off is discoverability: a developer who omits the header may get a default version they did not intend. Mitigate with explicit 400 responses when the version header is missing on write endpoints, and echo the resolved version in response headers (Api-Version-Used: 2).

Content negotiation extends the idea: the same URL returns different representations based on Accept. This shines when versioning response format (JSON vs XML) rather than business logic, but vendor media types (application/vnd.company.user.v2+json) bundle format and schema version together. Proxies and browser devtools show the negotiated type clearly in network tabs.

Query parameter versioning (and why it is rare)

GET /users?api_version=2 appears in legacy systems but is generally discouraged. Query strings are logged everywhere — access logs, analytics, referrer headers — and developers forget to append the parameter on POST bodies where there is no query string. If you already use path or header versioning, do not add a third mechanism. One source of truth prevents "works in staging with ?v=2 but production defaults to v1" bugs.

GraphQL, gRPC, and versioning without /v1

GraphQL exposes a single endpoint (/graphql) and a schema that evolves through deprecation directives rather than URL prefixes. Add new fields freely; mark old fields @deprecated(reason: "Use user.emailAddress"); remove them only after telemetry shows zero usage. Breaking changes require schema versioning at the federation gateway level or a new graph entirely — see our GraphQL design guide for field-level migration patterns.

gRPC uses protobuf package versioning and optional new service definitions (UserServiceV2) while keeping wire-compatible field numbers. Never reuse field numbers; add reserved ranges when deleting fields. Clients generated from .proto files pin to a specific stub version — treat proto changes like compiled API contracts, not like mutable JSON.

Deprecation, sunset headers, and migration playbooks

Shipping v2 without a retirement plan for v1 doubles your maintenance burden indefinitely. Standard practice:

  1. Announce deprecation in changelog, docs, and response headers.
  2. Measure traffic per version — log api_version on every request.
  3. Notify active integrators by email when their API keys still hit old versions.
  4. Sunset with a published date; return 410 Gone or 301 after.

HTTP provides machine-readable signals. The Sunset header (Sunset: Sat, 01 Nov 2026 00:00:00 GMT) tells clients when the resource or version stops working. Pair it with Deprecation (Deprecation: true) and a Link header pointing to migration docs (Link: <https://docs.example.com/migrate-v2>; rel="sunset"). Automated client libraries can surface warnings without parsing HTML changelogs.

Typical enterprise support window: 12–24 months for major versions, 90 days minimum notice before hard shutdown. B2B contracts may require longer — encode SLAs in your versioning policy so sales and engineering agree.

Parallel deployment patterns

Run v1 and v2 handlers side by side behind one gateway. Shared database with compatibility views, or v2 reads from a projection table fed by the same events. Avoid "big bang" cutovers: use shadow traffic (duplicate requests to v2, compare responses) before flipping production reads. For write paths, ensure idempotency keys work across versions so retries during migration do not double-charge.

Versioning pagination, errors, and webhooks together

Version bumps often touch cross-cutting concerns, not just resource bodies. If pagination changes from offset to keyset in v2, webhook payloads that embed list snapshots need the same cursor format. Error envelopes should stay structurally similar — keep code, message, and request_id keys even when error codes expand — so client middleware does not fork per version.

Webhooks are the hidden versioning debt. Subscribers register a URL once; you cannot upgrade their parser. Common pattern: include api_version in the webhook JSON body and version the event type string (payment.completed.v2). Never change the meaning of an existing event name without a new type suffix.

Strategy comparison

Strategy Best for Watch out for
URL path (/v1/) Public REST, obvious docs, multi-service routing URL sprawl, CDN cache key fragmentation
Header / media type Stable resource URLs, mobile apps, CDN-friendly GETs Missing header defaults, harder to grep in logs
Schema deprecation (GraphQL) Single endpoint, field-level telemetry Removed fields break queries without compile-time notice
Protobuf / gRPC services Internal microservices, strong typing Field number discipline; coordinated client stub releases

Production checklist

  1. Publish a written breaking-change policy; default to additive evolution.
  2. Pick one primary versioning mechanism (path or header) — do not mix three.
  3. Log resolved API version on every request; dashboard traffic by version.
  4. Return Api-Version-Used (or equivalent) in response headers.
  5. Emit Deprecation and Sunset headers before shutdown.
  6. Link migration guides from error responses on deprecated endpoints.
  7. Support at least one previous major version for a documented window.
  8. Version webhook event types and include api_version in payloads.
  9. Run contract tests per version in CI; block deploy if v1 fixtures break.
  10. Coordinate gateway routes, rate limits, and auth scopes per version tier.

Key takeaways

  • Version sparingly — additive changes avoid migration pain for most updates.
  • Path versioning is obvious; header versioning keeps URLs clean — pick one and document defaults.
  • Breaking means clients must change code — treat type, status, and pagination changes as major bumps.
  • Deprecation is a schedule, not a banner — Sunset headers, telemetry, and notified integrators.
  • Webhooks and errors version too — cross-cutting contracts outlive individual resource handlers.

Related reading