URI Path Versioning vs Accept-Header Versioning
You have shipped v1, v2 is ready, and both must serve traffic through the same gateway. The decision in front of you is where the version signal lives: in the path as /v2/orders/42, or in the Accept header as application/vnd.api+json;version=2. It looks like a matter of taste, but the two choices diverge sharply on exactly the things an operator cares about — CDN cacheability, URL stability, debuggability, and how easy the version is to spoof. This page is a direct head-to-head so you can pick with the trade-offs in front of you instead of discovering them in an incident.
Prerequisites
This assumes you have read the overview of API versioning strategies, which frames path, header, and content-negotiation versioning as three placements of one signal, and the parent Advanced Routing & API Versioning pillar for the routing-layer invariants. The match primitives used below — prefix routes, safe_regex, header predicates — are covered under path and header-based routing.
The core difference in one line
Path versioning makes the version part of the resource identifier; Accept-header versioning makes it part of the representation negotiation of a stable resource. /v1/orders/42 and /v2/orders/42 are two URLs; GET /orders/42 with two different Accept values is one URL asking for two representations. Every downstream trade-off falls out of that single distinction — most of all how caches behave, because caches key on URLs first and headers only when told to.
Path versioning: config for each gateway
Kong 3.x — one route per major, path stripped so the upstream never sees the version:
# Kong 3.x — path versioning
_format_version: "3.0"
services:
- name: orders-v2
url: http://orders-v2.internal:8080
routes:
- name: orders-v2
paths: ["~/v2/orders(?:/.*)?$"] # anchored RE2, avoids /v2/orders-archive
strip_path: true # upstream receives /orders/...
plugins:
- name: response-transformer
config:
add: { headers: ["X-API-Version:2"] } # echo resolved version
Envoy 1.32+ — prefix match plus prefix_rewrite, newest listed first:
# Envoy 1.32+ — path versioning
route_config:
virtual_hosts:
- name: orders
domains: ["api.example.com"]
routes:
- match: { prefix: "/v2/orders" }
route: { cluster: orders_v2, prefix_rewrite: "/orders" }
response_headers_to_add:
- header: { key: "x-api-version", value: "2" }
- match: { prefix: "/v1/orders" }
route: { cluster: orders_v1, prefix_rewrite: "/orders" }
The version is now baked into the URL, so a CDN in front of the gateway caches /v2/orders/42 and /v1/orders/42 as distinct objects with zero extra configuration. That is the headline advantage: correct caching for free.
Accept-header versioning: config for each gateway
Kong 3.x — match the version parameter inside the vendor media type, and Vary on Accept:
# Kong 3.x — Accept-header (content negotiation) versioning
_format_version: "3.0"
services:
- name: orders-v2
url: http://orders-v2.internal:8080
routes:
- name: orders-v2-accept
paths: ["/orders"] # URI is stable across versions
headers:
accept: ["~*application/vnd\\.api\\+json;\\s*version=2"]
plugins:
- name: response-transformer
config:
add:
headers:
- "Vary:Accept"
- "Content-Type:application/vnd.api+json;version=2"
Envoy 1.32+ — anchored safe_regex on the accept header, tolerating surrounding media-type noise:
# Envoy 1.32+ — Accept-header versioning
routes:
- match:
prefix: "/orders"
headers:
- name: "accept"
string_match:
safe_regex: { regex: ".*application/vnd\\.api\\+json;\\s*version=2.*" }
route: { cluster: orders_v2 }
response_headers_to_add:
- header: { key: "Vary", value: "Accept" }
The URL /orders/42 is identical for every version. That stability is the headline advantage — and Vary: Accept is the headline liability, because it invites cache-key fragmentation unless you normalise Accept at the edge first.
Decision matrix
| Dimension | Path versioning (/v2/…) | Accept-header versioning |
|---|---|---|
| CDN cacheability | Version in URL → cache key for free | Needs Vary: Accept + edge normalisation |
| URL stability | Changes per version (URL churn) | Stable across all versions |
| Debuggability | Version visible in every log/URL | Hidden in a header; easy to overlook |
| Client migration | Change base path in the client | Change one header value |
| RESTfulness | Version conflated with identity | Cleanest — negotiates representation |
| Spoofing surface | Low — path is explicit | Medium — parse + allowlist the parameter |
| Match cost | O(1) radix prefix | Regex parse of a compound header |
| Best fit | Public, CDN-fronted APIs, SDKs | Hypermedia/JSON:API, stable-URL internal APIs |
The practical rule: default to path versioning for public, CDN-fronted APIs where cache correctness and debuggability dominate, and reach for Accept-header versioning when URL stability is a hard requirement — long-lived hypermedia links, or an internal API whose consumers treat the URL as a permanent key — and you are willing to run the Vary and normalisation machinery.
CDN and caching implications
Caches key on the URL, then on whatever headers Vary names. Path versioning aligns perfectly with that model: distinct URLs, distinct cache entries, no Vary needed. Accept-header versioning fights it. Vary: Accept is correct — without it a cache can serve a v1 body to a v2 request — but Accept is high-cardinality, so the cache stores a separate object per distinct Accept string and the hit ratio craters.
There are two survivable patterns for Accept-header versioning behind a CDN:
- Normalise at the edge. Before the response is cached, rewrite
Acceptdown to the single version token (or copy the parsed version into a dedicatedX-Resolved-Versionheader) andVaryon that low-cardinality value instead of rawAccept. - Do not cache negotiated responses at the CDN. Terminate versioning at the gateway and mark the responses private, accepting the origin load. Only viable at modest traffic.
Path versioning needs neither workaround, which is why CDN-heavy public APIs gravitate to it.
Gotchas and failure signals
- A v2 client gets a v1 body. The cache served the wrong representation because the Accept-versioned route did not emit
Vary, or a shared cache ignored it. Signal: intermittent wrong-version responses correlated with cache hits. Fix:Varyon the routing header and verify every cache layer honours it. - CDN hit ratio collapses after enabling header versioning. Cache-key fragmentation on raw
Accept. Signal: origin request rate jumps, cache hit ratio drops toward zero. Fix: normaliseAcceptto a version token at the edge before caching. - Upstream 404s immediately after a path-versioning rollout.
strip_path/prefix_rewritemismatch — the upstream is receiving/v2/orders/...when it expects/orders/..., or vice versa. Signal: healthy upstream, consistent 404s only through the gateway. Fix: confirm the rewrite against the upstream’s actual routes. - Regex over-match on the path.
/v2/ordersas a bare prefix also swallows/v2/orders-archive. Fix: anchor the pattern ((?:/.*)?$) and rely on prefix-length ordering. - An unknown Accept version falls through to a default backend. A permissive regex or a catch-all route routes
version=9somewhere unintended. Fix: allowlist versions and reject unknown values with a400.
Validation
- Path routes are anchored (
$) and use RE2 /safe_regex;/v2/ordersdoes not match/v2/orders-archive. -
strip_path/prefix_rewriteis verified against the upstream’s real path expectations. - Every Accept-versioned response emits a
Varyheader, and each cache layer is confirmed to honour it. -
Acceptis normalised to a low-cardinality version token at the edge before any CDN caching. - Unknown or unsupported versions are rejected with
400, not routed to a default. - The resolved version is echoed to the client in a response header for both strategies.
- CDN hit ratio is monitored per version after any versioning change.
FAQ
Is path versioning or Accept-header versioning better for a public API?
For a public, CDN-fronted API, path versioning is usually the better default. The version lives in the URL, so it enters the CDN cache key automatically, every access log and support ticket shows the version, and client SDKs pin it as a base path. Accept-header versioning keeps URLs stable and is more RESTful, but it forces you to manage Vary headers and edge normalisation to avoid cache fragmentation, which is a real operational cost most public APIs do not need.
Why does Accept-header versioning hurt CDN cache hit ratio?
Correct Accept-header versioning requires Vary: Accept so caches distinguish versions. But Accept is a high-cardinality header — browsers and clients send many distinct strings — so keying the cache on the raw Accept value creates a separate cache object per variation and hit ratio collapses. The fix is to normalise Accept down to a single version token at the edge before the response is cached, or Vary on a dedicated low-cardinality header instead.
Can I run path and Accept-header versioning at the same time?
You can, but pick one as authoritative and make precedence explicit. A common pattern is path versioning as the public contract and an Accept parameter only for fine-grained representation negotiation within a major. If both can carry a version, the gateway must define a deterministic precedence — for example path wins — and reject or ignore the other rather than silently letting one override the route the client did not expect.
What failure signal indicates a versioning misconfiguration at the gateway?
Watch for a v2 client receiving a v1 response body (a cache serving the wrong version because Vary is missing), a sudden CDN hit-ratio drop after enabling header versioning (cache-key fragmentation on Accept), and upstream 404s right after a rollout (path stripping mismatch). Each maps to a distinct root cause: missing Vary, un-normalised Accept, and wrong prefix_rewrite or strip_path respectively.
Parent: API Versioning Strategies
Related
- Semantic Versioning for REST APIs at the Gateway — how major/minor/patch maps onto whichever placement you pick here.
- Path & Header-Based Routing — prefix, regex, and header match mechanics behind both strategies.
- Advanced Routing & API Versioning — routing-layer invariants and the broader versioning context.