URL Path vs Header Versioning
Part of the API Versioning & Deprecation reference. This page compares the three ways to encode which version of a resource a client wants — a segment in the URL path (/v2/orders), a custom request header (X-API-Version: 2), or the media type in Accept (media-type versioning with content negotiation) — and shows how each choice ripples through shared caches, gateway routing, your OpenAPI contract, and the SDKs you generate from it.
Problem framing
Two failure modes drive most versioning incidents, and they sit at opposite ends of the same design decision.
The first is a client pinned to a URL that shifts underneath it. If your “current” endpoint is /orders with no version marker and you change the response shape, every integrator breaks at once with no migration window. The version identifier — wherever you put it — is the contract that lets old and new clients coexist while you roll a breaking change forward.
The second is a shared cache keyed without Vary. The moment the version selector moves off the URL and into a request header, the URL alone no longer uniquely identifies a representation. A CDN, reverse proxy, or browser cache that keys only on the URL will store whichever version happened to be requested first and serve it to everyone — a v1 client receives a v2 body, or vice versa. This is cache poisoning by omission, and it is silent until a consumer files a bug that you cannot reproduce because your own cache is warm with the correct variant.
These two problems frame the whole trade-off. Putting the version in the path makes it impossible to forget the cache key, because the URL is the key. Putting it in a header keeps URLs clean and RESTful — the same resource has one canonical URL across versions — but hands you the full responsibility for Vary correctness, default resolution, and gateway routing that reads request headers rather than path prefixes. The caching implications here are the same ones covered in depth under Statelessness & Caching Strategies; versioning is just the highest-stakes place they show up.
The rest of this guide treats all three strategies as first-class, declares each in an OpenAPI 3.1 contract, and gives you routing, negotiation, validation, and SDK guidance for each.
Three identifier strategies at a glance
Declaring versions in OpenAPI 3.1
A version scheme that lives only in framework code drifts from documentation and confuses generators. Declare it in the contract instead. For URL-path versioning, the servers block carries the version in the base path:
# openapi.yaml (OpenAPI 3.1.0) — URL-path versioning
openapi: 3.1.0
info:
title: Orders API
version: 2.4.0
servers:
- url: https://api.example.com/v2
description: Version 2 (current)
- url: https://api.example.com/v1
description: Version 1 (deprecated, sunset 2026-12-31)
paths:
/orders:
get:
operationId: listOrders
responses:
"200":
description: A page of orders
content:
application/json:
schema: { $ref: "#/components/schemas/OrderPage" }
For header versioning, keep a single unversioned servers entry and model the selector as a header parameter — a reusable $ref so every operation inherits it. The media-type approach expresses the version inside the content map key of Accept:
# openapi.yaml (OpenAPI 3.1.0) — header + media-type versioning
openapi: 3.1.0
info: { title: Orders API, version: "2.4.0" }
servers:
- url: https://api.example.com
components:
parameters:
ApiVersion:
name: X-API-Version
in: header
required: false # omitted → server resolves the default
schema:
type: string
enum: ["1", "2"]
default: "1" # oldest supported, never "latest"
description: Selects the response representation version.
paths:
/orders:
get:
operationId: listOrders
parameters:
- $ref: "#/components/parameters/ApiVersion"
responses:
"200":
description: A page of orders
headers:
Vary:
schema: { type: string }
description: Must include Accept and X-API-Version.
content:
application/vnd.example.v2+json:
schema: { $ref: "#/components/schemas/OrderPageV2" }
application/vnd.example.v1+json:
schema: { $ref: "#/components/schemas/OrderPageV1" }
Note that OpenAPI 3.1 lets you attach a Vary response header to the operation — a machine-readable reminder that this representation is negotiated. The differences between how 3.0 and 3.1 model these schemas are covered in OpenAPI 3.0 vs 3.1 Schema Dialect Differences.
RFC / standard alignment
Only one of the three strategies has a formal standards pedigree — worth knowing when you defend a choice in review.
| Concern | Standard | What it says | Applies to |
|---|---|---|---|
| Content negotiation | RFC 9110 §12 | Server selects a representation from Accept; the response should tell caches which request headers were used |
Media-type and header versioning |
Accept header |
RFC 9110 §12.5.1 | Client expresses preferred media types with optional q weights |
Media-type versioning |
Vary header |
RFC 9110 §12.5.5 | Names the request headers a cache must match to reuse a stored response | Header + media-type versioning |
Content-Type |
RFC 9110 §8.3 | Labels the media type actually returned — echo the negotiated vnd.* type |
Media-type versioning |
Link relations |
RFC 8288 | Advertise alternate versions via Link: <…>; rel="alternate" or successor-version |
All strategies |
Custom X- headers |
(no active RFC) | RFC 6648 deprecates the X- convention but does not forbid it; a bare custom header has no negotiation semantics for caches |
Custom-header versioning |
| URL-path version | (no formal RFC) | REST style guidance only; /v2 is convention, not a standard — which is exactly why caches handle it trivially |
URL-path versioning |
The takeaway: media-type versioning is the only scheme HTTP was explicitly designed for, header versioning borrows Vary semantics without the negotiation machinery, and URL-path versioning has no standard at all — its correctness comes for free precisely because the version is part of the cache key rather than a header a cache must be told about.
Implementation walkthrough — step 1: gateway routing by path
Path-based versioning routes at the edge with no body inspection. The gateway (or an Express app acting as one) dispatches each version prefix to the correct upstream. Because the version is in the URL, every downstream cache keys on it automatically.
// gateway/index.js — Node.js + Express edge router for URL-path versioning
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
// Each major version maps to an independently deployable upstream.
const UPSTREAMS = {
'/v1': 'http://orders-v1.internal:8080',
'/v2': 'http://orders-v2.internal:8080',
};
for (const [prefix, target] of Object.entries(UPSTREAMS)) {
app.use(
prefix,
createProxyMiddleware({
target,
changeOrigin: true,
// Strip the version prefix so the upstream sees a clean /orders path.
pathRewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
// The version is already in the URL, so the cache key is correct
// without any Vary handling at this layer.
on: {
proxyRes: (proxyRes) => {
proxyRes.headers['x-served-version'] = prefix.slice(1);
},
},
}),
);
}
// Unversioned requests are a client bug — fail loud, do not guess.
app.use((req, res) => {
res.status(404)
.type('application/problem+json')
.json({
type: 'urn:api:errors:v1:version-missing',
title: 'API version required',
status: 404,
detail: `Prefix the path with a supported version, e.g. /v2${req.path}.`,
});
});
app.listen(8000);
The key property: no request-body or header parsing on the hot path, and the CDN in front of this gateway needs zero version-specific configuration because /v1/orders and /v2/orders are simply different URLs.
Implementation walkthrough — step 2: header / media-type negotiation with correct Vary
When the version moves into a header, the server becomes responsible for the cache contract. The FastAPI handler below resolves the version from X-API-Version first, falls back to parsing a vnd.example.vN+json media type out of Accept, defaults a missing selector to the oldest version, and — critically — always emits Vary: Accept, X-API-Version so shared caches partition their stored responses per version.
# app/main.py — FastAPI header + media-type negotiation with correct Vary
import re
from fastapi import FastAPI, Request, Response
app = FastAPI()
SUPPORTED = {"1", "2"}
DEFAULT_VERSION = "1" # oldest supported, never "latest"
_ACCEPT_RE = re.compile(r"application/vnd\.example\.v(\d+)\+json")
def resolve_version(request: Request) -> tuple[str, bool]:
"""Return (version, was_defaulted). Header wins, then Accept, then default."""
header = request.headers.get("x-api-version")
if header in SUPPORTED:
return header, False
match = _ACCEPT_RE.search(request.headers.get("accept", ""))
if match and match.group(1) in SUPPORTED:
return match.group(1), False
return DEFAULT_VERSION, True # nothing usable was sent
@app.get("/orders")
async def list_orders(request: Request, response: Response):
version, defaulted = resolve_version(request)
# ALWAYS advertise every request header that selected this representation,
# or a shared cache will serve one client's version to another.
response.headers["Vary"] = "Accept, X-API-Version"
response.headers["Content-Type"] = f"application/vnd.example.v{version}+json"
response.headers["X-Served-Version"] = version
if defaulted:
# Tell the client it was routed to a version it did not request.
response.headers["Deprecation"] = "true"
response.headers["Link"] = (
'</orders>; rel="successor-version"'
)
if version == "2":
return {"orders": [], "page": {"cursor": None}} # v2 shape
return {"data": [], "next": None} # v1 shape
Two rules make this correct. First, Vary must name every request header that can change the response — here both Accept and X-API-Version, even though only one is used per request, because a cache cannot know in advance which one a given client sends. Second, the default branch signals itself: a client that forgot the header gets a working response and a Deprecation marker, rather than silently riding whatever version you happen to default to today.
Edge-case handling
CDN cache poisoning without Vary. This is the headline failure of header-based versioning. If the origin omits Vary: X-API-Version, a CDN stores the first response it sees under the bare URL key and serves it to every subsequent client regardless of their header. A v1 client warms the cache, then v2 clients receive v1 bodies for the whole TTL. The fix is unconditional: emit Vary on every negotiated response, and set Cache-Control: private on any endpoint where you cannot guarantee the CDN honours Vary on custom headers (many edge caches ignore Vary on non-standard header names entirely — a strong argument for either URL-path versioning or Accept-based media-type versioning at the CDN tier).
Default version when the header is omitted. Pin the default to the oldest supported version, never latest. If new clients that forget the header default to the newest major, then every future major release retroactively breaks existing well-behaved traffic. Defaulting to the oldest means a missing header is stable forever, and you use the Deprecation header to nudge callers toward sending an explicit version.
Per-representation media-type versioning. With application/vnd.example.v2+json you can version individual resources independently — an orders representation can reach v3 while customers stays at v1 — because the version rides the representation, not a global path prefix. This granularity is powerful but multiplies the Vary/negotiation surface; it is covered end-to-end in media-type versioning with content negotiation.
Mixed schemes. A coarse major version in the path (/v2) plus a fine-grained representation selector in Accept is a legitimate hybrid: the path stays the cache key, and the media type adds minor variants on top. Keep the header-selected axis small so the Vary contract stays predictable.
Validation and testing patterns
Encode the version-scheme rules as automation so they cannot rot. A Spectral rule asserts that every declared server ends in a version segment — catching an unversioned servers URL before it ships:
# .spectral.yaml — enforce a version segment on every server URL
rules:
server-url-has-version:
message: "Every server url must end in /v<number> (e.g. /v2)"
severity: error
given: "$.servers[*].url"
then:
function: pattern
functionOptions:
match: "/v[0-9]+$"
Pair the static rule with a runtime contract test that proves the Vary header actually names every version-selecting header — the single check that prevents cache poisoning in production:
# tests/contract/test_vary.py — assert the Vary contract for header versioning
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_vary_lists_all_version_selectors():
res = client.get("/orders", headers={"X-API-Version": "2"})
vary = {v.strip().lower() for v in res.headers["vary"].split(",")}
# Both selectors MUST appear, regardless of which one this request used.
assert {"accept", "x-api-version"} <= vary
assert res.headers["content-type"] == "application/vnd.example.v2+json"
def test_missing_header_defaults_to_oldest_and_signals_deprecation():
res = client.get("/orders") # no version sent
assert res.headers["x-served-version"] == "1" # oldest, not latest
assert res.headers.get("deprecation") == "true"
Run the Spectral lint in CI and the contract tests against a live instance (or a Prism mock) on every pull request so a regression in either the declared scheme or the runtime Vary behaviour blocks the merge.
SDK generation impact
The version identifier’s location changes the shape of every generated client, so choose with the developer experience in mind.
URL-path versioning surfaces as the client’s base path / server URL. Generators bake /v2 into the basePath or Configuration.host, so upgrading a consumer to v2 means bumping the SDK version and re-pointing its base URL — an explicit, greppable change.
// TypeScript client generated from a /v2 servers block
import { Configuration, OrdersApi } from '@example/orders-sdk';
const api = new OrdersApi(
new Configuration({ basePath: 'https://api.example.com/v2' }),
);
// The version is structural: switching to v1 means a different basePath.
await api.listOrders();
Header versioning surfaces as a default header the SDK sends on every request — invisible in call sites, which cuts both ways: upgrades are a one-line config change, but the version in use is not obvious from reading the code.
# Python client generated from an X-API-Version header parameter
from example_orders import Client
client = Client(
base_url="https://api.example.com",
headers={"X-API-Version": "2"}, # applied to every request
)
Go, media-type flavour — generators map the negotiated media type onto the Accept header the transport sets, so the version rides content negotiation:
// Go client configured for media-type versioning
cfg := orders.NewConfiguration()
cfg.DefaultHeader["Accept"] = "application/vnd.example.v2+json"
client := orders.NewAPIClient(cfg)
The practical rule: URL-path versions produce the most legible SDK diffs (the base path changes), while header and media-type versions produce the most ergonomic upgrades (one config line) at the cost of the version being hidden from call sites. Teams that value auditability of which version a service calls tend to prefer path versioning for exactly this reason. How you pin those generated clients across releases is the subject of SDK Version Pinning.
Anti-patterns quick-reference
| Anti-pattern | Correct approach |
|---|---|
Header versioning without a Vary header |
Emit Vary: Accept, X-API-Version on every negotiated response |
Defaulting an omitted version to latest |
Default to the oldest supported version; signal it with Deprecation |
| Unversioned “current” endpoint you mutate in place | Introduce a version identifier before the first breaking change |
Relying on CDN Vary for a custom X- header |
Prefer URL-path or Accept-based versioning at the edge; many caches ignore Vary on non-standard headers |
| Version in the path and a conflicting header | Pick one authoritative axis; if mixing, keep the path as the cache key |
Minor/patch numbers in the URL (/v2.4.1) |
Version only on breaking (major) boundaries; carry minors in the body or a header |
servers block with no version segment |
Enforce a /v[0-9]+$ Spectral rule so every server URL is explicit |
Returning Content-Type: application/json for a negotiated vnd.* request |
Echo the negotiated media type so caches and clients agree on the representation |
FAQ
Is URL-path or header versioning better for cacheability?
URL-path versioning is the safest default for shared caches: each version has a distinct URL, so CDNs and proxies key on it automatically with no configuration. Header and media-type versioning require an explicit Vary header naming every request header that selects a representation, or shared caches will serve the wrong version to some clients.
What happens when a client omits the version header?
You must define an explicit default resolution policy. Pin the default to the oldest supported version, never to latest, so a new major release cannot silently break clients that never sent the header. Emit a Deprecation or Sunset header on defaulted responses so consumers know they are being routed to a version they did not request.
Can I mix URL-path and header versioning in the same API?
Yes, and a common pattern is a coarse major version in the path (/v2) plus a finer representation selector in the Accept header or a custom header for minor variants. Keep the path version as the cache key and treat header variants as additive fields on that representation so the Vary contract stays small and predictable.
Related
- API Versioning & Deprecation — parent reference covering the full versioning and deprecation strategy
- Media-Type Versioning with Content Negotiation — the
Accept-based scheme in depth, includingq-weighted negotiation - OpenAPI 3.0 vs 3.1 Schema Dialect Differences — how the two dialects model versioned schemas and response headers
- Sunset & Deprecation Headers — signalling retirement timelines once you cut over between versions
- SDK Version Pinning — pinning generated clients to the API version they were built against