Choosing Between URL, Header and Media Type Versioning
Problem: you are about to publish a second version of an API and have to decide where the version identifier lives. The decision looks cosmetic, is effectively permanent, and quietly determines how your caches behave, how readable your logs are, and how much gateway configuration every future version costs. This guide is part of API Versioning and Deprecation Policies, which covers the surrounding policy — what triggers a version bump and how a version is retired.
The three viable placements are the URL path, a request header, and the media type. All three work. They differ in what they cost you operationally, and the right answer depends on facts about your consumers and your infrastructure rather than on architectural principle.
The Three Schemes in Practice
Here is the same request in each scheme, with the response headers that matter.
# 1. URL path
GET /v2/orders/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60
# 2. Custom request header
GET /orders/42 HTTP/1.1
Host: api.example.com
X-API-Version: 2
HTTP/1.1 200 OK
Content-Type: application/json
Vary: X-API-Version # without this, caches conflate the versions
Cache-Control: public, max-age=60
# 3. Media type (content negotiation)
GET /orders/42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.order.v2+json
HTTP/1.1 200 OK
Content-Type: application/vnd.example.order.v2+json
Vary: Accept
Cache-Control: public, max-age=60
The Vary header in schemes two and three is not optional. Omitting it is the single most common versioning bug in production: a shared cache stores the v2 response and serves it to a v1 client, and the failure is intermittent, environment-dependent, and invisible in the application’s own logs.
How Each Scheme Scores
The decision comes down to four properties, and no scheme wins all four.
Visibility. A version in the path appears in every access log, dashboard, rate-limit rule and CDN report without configuration. A version in a header appears nowhere unless somebody remembered to log it — and when you come to run the traffic query described in the versioning policy, that omission is what stops you retiring the old version.
Caching. Path versioning gets correct cache behaviour for free, because the cache key already contains the version. The other two require Vary on every response and correct configuration in every intermediary, including ones you do not own.
Resource identity. Header and media type versioning keep one URL per resource, which is the theoretically correct model and genuinely useful when resources are linked to each other: a hypermedia link does not have to encode a version that may not match the client’s.
Routing cost. A path prefix routes with a string match at any gateway. Header routing needs a rule per gateway, and media type routing needs content negotiation support, which older infrastructure sometimes implements incorrectly.
Modelling the Choice in OpenAPI
Whichever scheme you choose, keep one document per major version. A single document describing both versions makes every diff ambiguous, because the tool cannot tell which contract a path belongs to.
# URL versioning — the version is in the server URL, nowhere else
openapi: 3.1.1
info: { title: Orders API, version: 2.14.3 }
servers:
- url: https://api.example.com/v2
paths:
/orders/{id}: # note: no version in the path template
get: { operationId: getOrder }
# Header versioning — the version is a required parameter on every operation
components:
parameters:
ApiVersion:
name: X-API-Version
in: header
required: true
schema: { type: string, enum: ["2"] }
description: Contract version. Requests without it are rejected with 400.
paths:
/orders/{id}:
get:
parameters:
- $ref: '#/components/parameters/ApiVersion'
Declaring the header as a required parameter with a single-value enum is what makes the scheme enforceable: a request-validating gateway or a Prism mock will reject a call that omits it, and the reusable parameter component keeps it declared once rather than on every operation.
Enforcing the Decision
A versioning scheme decays the moment one endpoint deviates. Lint for it:
# .spectral.yaml — reject a path that carries a version segment when the
# house scheme is header-based (invert the logic for URL-based schemes).
extends: ["spectral:oas"]
rules:
no-version-in-path:
description: Versions belong in X-API-Version, not in the path.
given: "$.paths"
severity: error
then:
field: "@key"
function: pattern
functionOptions:
notMatch: "^/v\\d+/"
Run it in the same job as the rest of the contract checks so a deviating endpoint fails review rather than shipping.
Verification
Prove the scheme behaves correctly through the real infrastructure, not just in the service:
# Header scheme: the two versions must not share a cache entry.
curl -sD - -H 'X-API-Version: 2' https://api.example.com/orders/42 -o /dev/null | grep -i '^vary'
# vary: X-API-Version
# Request the same URL as v1 and confirm a different body shape comes back.
curl -s -H 'X-API-Version: 1' https://api.example.com/orders/42 | jq 'keys'
# [ "currency", "id", "status", "total" ]
curl -s -H 'X-API-Version: 2' https://api.example.com/orders/42 | jq 'keys'
# [ "id", "status", "total" ]
# A request with no version at all must not silently pick one.
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/orders/42
# 400
If the two versioned requests return identical bodies, the version is being read somewhere that is not the routing layer — usually a gateway stripping unknown headers before the request reaches the service.
Edge Cases and Caveats
- Header stripping at the edge. Many gateways and some corporate proxies drop unrecognised
X-headers. Verify from outside your network, not from a machine inside it, and prefer a header name your own gateway is configured to pass through explicitly. - Browsers and media type versioning. A browser sends its own
Acceptheader, so a media-type-versioned endpoint pasted into an address bar returns whatever your default is. This makes debugging noticeably harder and is a real argument against the scheme for developer-facing APIs. - Version negotiation in SDKs. Whichever scheme you pick, the generated client should set the version automatically and not expose it as a parameter callers can change. A caller who can override the version can pin themselves to a retired one.
- Sunsetting under header versioning. The
Sunsetheader applies to a resource, and under header versioning both versions share a URL. Send the deprecation headers only on responses to the deprecated version’s requests — the logic lives in the same place that reads the version, as described in implementing Sunset and Deprecation headers.
Migrating From One Scheme to Another
Teams do change scheme — usually from a header to a URL after discovering how much operational visibility the header cost. It is a breaking change for every client, so it runs through the same overlap discipline as any other.
Serve both for a window. Route /v2/orders and /orders with X-API-Version: 2 to the same handler, so a client can switch on its own schedule. Emit the deprecation headers only on the old scheme’s responses. Then log the scheme each caller uses, exactly as you would log a version, and retire the old scheme when the traffic reaches zero from identified consumers.
The one thing to avoid is a “temporary” dual-scheme period with no end date. Two schemes double the routing configuration, double the cache-key surface, and give every future endpoint an implicit choice about which convention to follow. Set the retirement date at the start, publish it, and treat it like any other sunset.
Frequently Asked Questions
Is URL versioning really unRESTful?
Strictly, yes: /v1/orders/42 and /v2/orders/42 are two identifiers for one resource, which conflicts with the idea that a URL names a thing rather than a representation of it. In practice the cost of that impurity is close to zero and the operational benefits are large, which is why most public APIs use it anyway.
Do I need the Vary header with header-based versioning?
Yes. Any cache between you and the client will otherwise serve a v1 response to a v2 request that differs only by a header it did not consider. Send Vary with your version header name on every cacheable response, and verify it with a real CDN rather than trusting configuration.
What happens if a client sends no version at all?
Decide deliberately and document it. Defaulting to the newest version silently breaks old clients the day you ship a new one; defaulting to the oldest keeps clients working but leaves them there forever. For most APIs, rejecting the request with a 400 that names the required parameter is the honest option for new integrations, with a grandfathered default for existing ones.
Can I version individual endpoints instead of the whole API?
You can, and it is tempting because most endpoints never change. It becomes painful the moment a client has to track which endpoint is at which version. If you go this way, version resources rather than operations, and publish a machine-readable index so a client can discover the current version per resource.
How do I model the version in OpenAPI?
For URL versioning, put it in the servers URL and keep one document per major version. For header or media type versioning, model the version as a required header parameter or in the response content media type, and still keep one document per major version so a diff tool can compare like with like.