There are three common API versioning strategies: put the major version in the URI path (/v2/orders), send it in a request header (Api-Version: 2), or put it in the media type the client asks for (Accept: application/vnd.example.v2+json). For most public HTTP APIs, URI path versioning is the practical default, because the version is visible in every log line, link and cURL command, and caches need no extra configuration. Header and media type versioning keep URIs stable but need a Vary header and stricter client discipline. Whichever you pick, two decisions matter more: a written rule for what counts as a breaking change, and a deprecation process that tells clients when an old version will stop answering.
How the three strategies compare
| URI path | Request header | Media type | |
|---|---|---|---|
| Request | GET /v2/orders/42 | GET /orders/42 with Api-Version: 2 | GET /orders/42 with Accept: application/vnd.example.v2+json |
| Visible in logs and links | Yes | Only if you log the header | Only if you log the header |
| Caching | Different URIs, so nothing extra | Response must send Vary: Api-Version | Response must send Vary: Accept |
| Routing | Path prefix at the gateway | Header match at the gateway or in the app | Content negotiation in the app |
| Unsupported version | 404 Not Found | 400 Bad Request | 406 Not Acceptable |
| Granularity | Whole API | Whole API | Per representation |
URI path versioning
Put only the major version in the path, and change it only for breaking changes. /v2 is a promise that everything under it keeps working. Minor and patch numbers belong in info.version in the OpenAPI document and in your changelog, not in URIs, or every additive release would force clients to rewrite their base URL. The main cost is that a resource has a different URI in each version, so links stored by clients (for example in a webhook payload) point at a specific version and keep pointing there.
Header versioning
A custom header keeps one URI per resource, but it complicates caching. RFC 9110 defines Vary as the response header that lists which request header fields had a bearing on choosing the response, so a shared cache that sees Vary: Api-Version stores the v1 and v2 bodies separately. Leave it out and a CDN can serve a v1 body to a v2 client. You also have to decide what a request without the header gets. Rejecting it with 400 is strict but safe. Defaulting to the oldest supported version keeps old clients working but means any client that forgets the header silently gets a contract it never tested against.
Media type versioning
Here the version is part of the representation the client asks for in the Accept header. It lets you version one resource without touching the rest of the API, and an unsupported version maps cleanly onto 406 Not Acceptable, which RFC 9110 uses when the resource has no representation acceptable to the client. In practice it is the hardest of the three for consumers: every request needs a custom Accept value, browser address bars cannot send one, and the response must carry Vary: Accept.
Choosing a strategy
| Scenario | Recommendation | Alternative |
|---|---|---|
| The API is public, or consumed by teams you do not talk to every week | URI path versioning with the major version only | Header versioning if stable URIs are a hard requirement and every response sends Vary |
| Clients are your own apps and you control every release | Header versioning, rejecting requests that omit the header | No versioning at all, shipping only additive changes |
| Only one or two resources need a second representation | Media type versioning for those resources | A new resource name, such as /shipment-quotes alongside /shipments |
What counts as a breaking change
A change is breaking if a client that worked yesterday can fail today without changing its code. That definition covers more than removed fields. The table lists the cases teams most often misjudge.
| Change | Breaking? | Why |
|---|---|---|
| Remove or rename a response field | Yes | Clients reading the old name get undefined |
| Change a field's type (number to string, string to object) | Yes | Typed clients fail to parse; arithmetic on strings misbehaves |
| Add a required request field or parameter | Yes | Existing requests start failing validation |
| Tighten validation (shorter maxLength, narrower enum on input) | Yes | Values that were accepted are now rejected |
| Change the status code for an existing outcome | Yes | Clients branch on status codes |
| Change a default, such as page size or sort order | Yes | Clients relying on the default get different data |
| Add a value to an enum in a response | Usually | Clients with an exhaustive switch hit an unknown case |
| Make a response field nullable | Usually | Clients that never checked for null crash |
| Add an optional request field or query parameter | No | Old requests are still valid |
| Add a response field | No, for tolerant clients | Breaks clients that reject unknown properties |
| Add a new operation or resource | No | Nothing existing changes |
Publish this table, or your own version of it, in your API docs. It turns arguments about whether a release needs /v3 into a lookup. The "usually" rows are where you decide policy: if you document that clients must handle unknown enum values, adding one becomes non-breaking by contract. The same goes for new response fields and clients that validate with additionalProperties: false, a trade-off covered in validating API responses against your OpenAPI schema.
Worked example: a v1 field that needed a unit
A shipments API returned weightKg as a number. Customers shipping to the US needed pounds, so v2 replaces it with a weight object holding a value and a unit. That is a removed field plus a new type, so it is breaking and gets a new major version. The spec below documents both versions, marks v1 with the operation-level deprecated flag, and declares the Deprecation and Sunset response headers so client generators and readers see them.
openapi: 3.1.0
info:
title: Shipments API
version: 2.3.0
paths:
/v1/shipments/{id}:
get:
summary: Get a shipment (v1)
deprecated: true
description: Deprecated on 2026-07-01. Sunset on 2027-01-31. Use GET /v2/shipments/{id}, which returns weight as an object with a unit.
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: The shipment in the v1 shape.
headers:
Deprecation:
description: Structured field date when v1 was deprecated (RFC 9745).
schema:
type: string
examples: ["@1782864000"]
Sunset:
description: HTTP-date after which v1 may stop responding (RFC 8594).
schema:
type: string
examples: ["Sun, 31 Jan 2027 23:59:59 GMT"]
content:
application/json:
schema:
type: object
required: [id, weightKg]
properties:
id:
type: string
weightKg:
type: number
"404":
description: No shipment with that id.
/v2/shipments/{id}:
get:
summary: Get a shipment
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: The shipment.
content:
application/json:
schema:
type: object
required: [id, weight]
properties:
id:
type: string
weight:
type: object
required: [value, unit]
properties:
value:
type: number
unit:
type: string
enum: [kg, lb]
"404":
description: No shipment with that id.APIForge scores this spec 100. We also scored a header-versioned variant of the same API, with paths /shipments and /shipments/{id}, a required Api-Version header parameter and a documented Vary response header. It scores 91, losing 9 of the 10 versioning points with the finding "No URL path versioning detected". The versioning check looks only for a /v1-style path segment, so it cannot see a version carried in a header or media type. If you chose header versioning on purpose, read that finding as a reminder to document the header on every operation, not as a defect in the design.
Deprecate with the Deprecation and Sunset headers
Two IETF standards cover retiring an API version. RFC 9745 defines the Deprecation response header. Its value is a structured field Date, written as @ followed by a Unix timestamp, and it can be in the past (already deprecated) or the future (deprecation planned). Deprecation does not change how the resource behaves; it is a signal. RFC 8594 defines the Sunset header, an HTTP-date after which the resource is likely to become unresponsive. RFC 9745 adds one ordering rule: the Sunset timestamp must not be earlier than the Deprecation timestamp. Both RFCs also register link relations, deprecation and sunset, so a response can point to a human-readable migration page.
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1782864000
Sunset: Sun, 31 Jan 2027 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-to-v2>; rel="deprecation"; type="text/html"
{"id": "shp_301", "weightKg": 2.4}RFC 8594 says clients should treat a sunset time as a hint, not a guarantee, and should read a sunset time in the past as "may become unavailable at any time". So the header does not replace direct notice. Email the owners of API keys that still call v1, and check your access logs for the version they use.
A deprecation timeline
Ship v2 and announce
Publish the migration guide and the breaking-change list. Mark v1 operations deprecated: true in the spec and say the dates in each description.
Send Deprecation and Sunset on every v1 response
Add both headers and the deprecation link relation. Clients and SDKs that log these headers will warn their developers without any action from you.
Track who still calls v1
Count v1 requests per API key every week and contact the remaining callers directly. Move the sunset date if a large customer cannot finish in time, and update the header when you do.
Retire v1
After the sunset date, return 410 Gone, which RFC 9110 defines as no longer available and likely permanent, with a problem details body that links to the migration guide. A 410 tells a confused developer far more than a 404 or a timeout.
Running two versions without two codebases
Keep one domain model and translate at the edge. In the shipments example, the service stores weight with a unit; the v1 handler converts it to kilograms and drops the unit, while the v2 handler returns it as stored. New features land in the shared core, and each version's handler decides what to expose. Copying the whole service per version doubles every bug fix and makes v1 drift from v2 in ways nobody documented. Run your contract tests against both versions until v1 is retired, because a change to the shared core is the most likely way to break the old contract by accident.
For a definition of the term and its vocabulary, see the API versioning glossary entry. Paths, naming and status codes are covered in REST API design best practices, and the HTTP status codes reference lists 400, 404, 406 and 410 side by side. To check a spec, paste it into the API quality score: the versioning category reports whether a path version exists, and the other categories list the naming, error and documentation findings for each operation.