API Versioning Strategies
Every API that outlives its first release eventually has to serve two contracts at once: the shape old clients depend on and the shape new clients want. Where you resolve that split determines how much operational pain versioning costs you. Resolving it inside application code means every service re-implements version parsing and every deploy risks a compatibility regression. Resolving it at the gateway turns versioning into a routing decision — the edge inspects one signal, selects the right upstream, and the services behind it stay blissfully single-version. This page sits under Advanced Routing & API Versioning and covers the three canonical ways to encode that signal — in the URI path, in a dedicated version header, or through content negotiation on the Accept header — and exactly how Kong, Envoy, and NGINX Plus route each one.
The three strategies are not interchangeable defaults you pick by taste. Each puts the version signal in a different part of the request, and that placement dictates how the router matches it, whether a CDN can cache the response without fragmenting, and how easy the version is to spoof. Getting the placement right is the whole game; the gateway config that follows is mechanical once the placement is chosen. The mechanics build directly on path and header-based routing, which covers the underlying match primitives this page applies to versioning specifically.
Mental model: where the version signal lives
A versioned request carries the version in exactly one of three locations, and the gateway’s job is to extract it and map it to an upstream:
- URI / path versioning puts the version in the path itself —
GET /v2/orders/42. The version is a literal, ordered segment the router matches before it ever looks at headers. It is the most visible, most cacheable, and least stable form: changing the version changes the URL, so every bookmark, log line, and CDN key changes with it. - Header / version-header versioning puts the version in a dedicated request header —
X-API-Version: 2— and keeps the URI constant. The resource identifier (/orders/42) stays stable across versions; only the routing header changes. This decouples the URL from the contract but pushes the version out of sight, where CDNs and humans both forget it exists. - Content-negotiation (media-type) versioning encodes the version inside the
Acceptheader as a vendor media type —Accept: application/vnd.api+json;version=2. This is the most RESTful in the Fielding sense: the client asks for a representation and the server negotiates it. It is also the most operationally fragile, because theAcceptheader is a compound value that CDNs and proxies handle inconsistently.
The invariant across all three: the gateway extracts the version, validates it against an allowlist, selects an upstream, and echoes the resolved version back to the client. Where implementations go wrong is skipping validation (letting clients spoof their way to an internal backend), skipping the echo (leaving clients unable to tell what they hit), or letting the raw signal leak into the CDN cache key unnormalised.
Comparing the three strategies
The diagram traces the same logical request — “get order 42, version 2” — through each strategy, showing which part of the request carries the version and what the gateway matches on.
Strategy 1: URI / path versioning
Path versioning is the workhorse of public APIs precisely because it demands nothing clever from the router. The version is an ordered path segment, so the gateway’s radix-tree matcher resolves it in constant time, the version is self-evident in every access log, and — critically — it becomes part of the CDN cache key with no extra configuration. Stripe, GitHub’s REST surface, and most public platforms lead with it for exactly this reason.
The trade-off is URL churn. Because the version is in the path, /v1/orders/42 and /v2/orders/42 are different resources as far as every cache, bookmark, and client SDK is concerned. Promoting a client from v1 to v2 is a code change on the client, not a header flip.
Kong 3.x models each major as its own route on a shared or dedicated service. Path stripping keeps the upstream oblivious to the version prefix:
# Kong 3.x declarative — path versioning, one route per major
_format_version: "3.0"
services:
- name: orders-v1
url: http://orders-v1.internal:8080
routes:
- name: orders-v1-route
paths: ["~/v1/orders(?:/.*)?$"] # anchored RE2 pattern
strip_path: true # upstream sees /orders/...
- name: orders-v2
url: http://orders-v2.internal:8080
routes:
- name: orders-v2-route
paths: ["~/v2/orders(?:/.*)?$"]
strip_path: true
plugins:
- name: response-transformer
config:
add:
headers: ["X-API-Version:2"] # echo resolved version
Kong evaluates prefix routes by length and priority; anchoring the regex with $ and a non-capturing group avoids the classic bug where /v2/orders-archive accidentally matches a /v2/orders prefix. strip_path: true is the single most common source of upstream 404s during a versioning rollout — the upstream must be built to receive the path without the version prefix.
Envoy 1.32+ expresses the same split as two route entries whose prefix (or safe_regex) discriminates the version, each pointing at its own cluster:
# Envoy 1.32+ — path versioning via prefix match + prefix_rewrite
route_config:
name: orders_routes
virtual_hosts:
- name: orders
domains: ["api.example.com"]
routes:
- match: { prefix: "/v2/orders" }
route:
cluster: orders_v2
prefix_rewrite: "/orders" # strip version for upstream
response_headers_to_add:
- header: { key: "x-api-version", value: "2" }
- match: { prefix: "/v1/orders" }
route:
cluster: orders_v1
prefix_rewrite: "/orders"
Envoy matches routes top-to-bottom, first-match-wins, so the more specific or newer version listed first is a deliberate ordering choice, not a default. prefix_rewrite is Envoy’s strip_path equivalent.
NGINX Plus (R32+) matches the version prefix as a location and proxies to a version-scoped upstream:
# NGINX Plus R32+ — path versioning with per-version upstream
upstream orders_v1 { zone orders_v1 64k; server 10.0.1.10:8080; }
upstream orders_v2 { zone orders_v2 64k; server 10.0.2.10:8080; }
server {
listen 443 ssl;
location /v2/orders/ {
rewrite ^/v2/orders/(.*)$ /orders/$1 break; # strip version
proxy_pass http://orders_v2;
add_header X-API-Version 2 always;
}
location /v1/orders/ {
rewrite ^/v1/orders/(.*)$ /orders/$1 break;
proxy_pass http://orders_v1;
add_header X-API-Version 1 always;
}
}
For a deeper head-to-head against content negotiation — including when the URL churn is worth avoiding — see URI path versioning vs Accept-header versioning.
Strategy 2: header / version-header versioning
Header versioning keeps the URI constant and moves the version into a dedicated header — X-API-Version: 2, or a vendor equivalent. The resource /orders/42 is now genuinely the same resource across versions; only the representation the gateway routes it to changes. This is attractive for internal service meshes and for teams that treat the URL as a permanent, cacheable identifier and refuse to fork it per version.
The cost is invisibility and cache complexity. The version no longer appears in URLs, logs, or the CDN cache key by default — two different versions of /orders/42 will collide in any cache that keys on URL alone unless you explicitly add the header to the cache key via Vary.
Kong 3.x routes on the header directly. A single route per major discriminated by headers:
# Kong 3.x — header versioning, stable URI, version in a header
_format_version: "3.0"
services:
- name: orders-v2
url: http://orders-v2.internal:8080
routes:
- name: orders-v2-hdr
paths: ["/orders"]
headers:
x-api-version: ["2"] # exact match, allowlisted value
plugins:
- name: response-transformer
config:
add:
headers: ["Vary:X-API-Version", "X-API-Version:2"]
The Vary: X-API-Version response header is not optional — it is the instruction that tells every downstream CDN and browser cache to key this response on the version header rather than serving a v1 body to a v2 request. Kong’s headers matcher is an exact-value allowlist, which doubles as spoofing defence: an unknown value simply fails to match this route.
Envoy 1.32+ uses a headers match predicate with string_match for safe validation:
# Envoy 1.32+ — header versioning with safe_regex allowlist
routes:
- match:
prefix: "/orders"
headers:
- name: "x-api-version"
string_match:
safe_regex: { regex: "^2$" } # RE2, no backtracking
route: { cluster: orders_v2 }
response_headers_to_add:
- header: { key: "Vary", value: "X-API-Version" }
- match:
prefix: "/orders"
headers:
- name: "x-api-version"
string_match: { safe_regex: { regex: "^1$" } }
route: { cluster: orders_v1 }
Anchoring with ^…$ and RE2 (safe_regex) is what keeps a header value from either matching too broadly or triggering catastrophic backtracking on a crafted input.
Strategy 3: content-negotiation (media-type) versioning
Content negotiation folds the version into the Accept header as a vendor media type: Accept: application/vnd.api+json;version=2. Conceptually it is the cleanest — the client is asking for a specific representation of a stable resource, which is exactly what Accept is for — and it is favoured by hypermedia and JSON:API style APIs. Operationally it is the hardest to run, because Accept is a compound, multi-valued header that CDNs, browsers, and proxies normalise inconsistently.
The gateway must parse the media-type parameter out of a header that may contain multiple types, quality values (q=), and whitespace variations. Match on the parameter, never on the whole header string.
Kong 3.x cannot fully parse media-type parameters with the built-in headers matcher, so a small pre-function extracts and normalises the version before routing, or a regex-capable route is used:
# Kong 3.x — content negotiation, normalise Accept before routing
_format_version: "3.0"
services:
- name: orders-v2
url: http://orders-v2.internal:8080
routes:
- name: orders-v2-accept
paths: ["/orders"]
headers:
# match the version parameter inside the vendor media type
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+ matches the accept header with an anchored, contains-style safe_regex, tolerating the surrounding media-type noise:
# Envoy 1.32+ — content negotiation on the Accept header
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" }
Vary: Accept is correct but dangerous: because Accept is high-cardinality (browsers send long, varied strings), keying the CDN on the raw Accept header shreds hit ratio. The mitigation, covered in the gotchas below, is to normalise Accept down to the single version token at the edge before the response reaches the cache.
Comparative implementation table
| Gateway | Path versioning | Header versioning | Content negotiation | Trade-off |
|---|---|---|---|---|
| Kong 3.x | paths route + strip_path per major |
headers exact-value allowlist |
headers.accept regex or pre-function normalise |
Simplest for paths; Accept needs regex/Lua and a Vary |
| Envoy 1.32+ | prefix match + prefix_rewrite |
headers.string_match.safe_regex |
anchored safe_regex on accept |
Most granular; first-match ordering must be deliberate |
| NGINX Plus R32+ | location + rewrite + per-version upstream |
map $http_x_api_version → upstream var |
map $http_accept regex → version var |
map blocks are fast but version logic lives outside routes |
| CDN cache key | version in URL, free | needs Vary on the version header |
needs Vary + edge normalisation |
Path caches cleanly; header/negotiation fragment without care |
| URL stability | changes per version | stable | stable | Path trades stable URLs for cache simplicity |
| Spoofing surface | low (path is explicit) | medium (validate + strip inbound) | medium (parse + allowlist) | Header forms need edge sanitisation |
NGINX Plus’s idiom for the header and negotiation cases is a map block that resolves the version into a variable the proxy_pass then consumes:
# NGINX Plus R32+ — header versioning via map to an upstream variable
map $http_x_api_version $orders_backend {
default orders_v1; # explicit, oldest-supported default
"2" orders_v2;
"1" orders_v1;
}
server {
listen 443 ssl;
location /orders/ {
proxy_pass http://$orders_backend;
add_header Vary "X-API-Version" always;
}
}
Operational gotchas
CDN cache-key fragmentation. The single most expensive versioning mistake is letting a high-cardinality header into the cache key. Vary: Accept on a content-negotiated API tells the CDN to store a separate object for every distinct Accept string it sees — and browsers send dozens of variations. Hit ratio collapses toward zero and origin load spikes. The fix is to normalise the routing signal at the edge: extract the version token, strip everything else, and Vary on a single low-cardinality header. With path versioning this problem does not exist, because the version is already in the URL that forms the cache key.
Default-version fallbacks that pin clients silently. A default route that catches requests with no version header is convenient and treacherous. It quietly binds every unversioned client to whatever “default” currently means, so the day you promote the default from v1 to v2 you break every client that never sent a version and never knew it was relying on the default. Two safer postures: fail closed with a 400 when a public API requires an explicit version, or pin the default to the oldest supported major and move it only on a published deprecation schedule. Always echo the resolved version in a response header so a client can detect which contract it actually received.
Version-header spoofing. Every client-supplied routing header is untrusted. Strip any inbound copy of your internal version header at the edge before your own logic runs, validate the value against a small allowlist with an anchored RE2 regex (not a permissive .* pattern), and reject unknown versions with a 400 rather than falling through to an internal or pre-release backend. Routing to a non-public version purely on an unvalidated client header is a data-exposure bug, not just a routing quirk.
Regex over-match on path versions. /v2/orders as a bare prefix also matches /v2/orders-archive. Anchor path patterns ((?:/.*)?$) and prefer prefix-length ordering so the specific route wins. The performance and correctness of prefix vs regex matching is covered in depth under path and header-based routing.
Backward-compatibility drift within a major. Routing solves major version selection; it does not stop a minor change from breaking clients inside a major. Pair gateway routing with explicit backward-compatibility contracts so that everything the gateway routes to v2 genuinely honours the v2 contract.
Production Configuration Checklist
- Exactly one version signal per request is authoritative — path, header, or
Accept— and the gateway never mixes precedence between them silently. - Path route regexes are anchored (
$) and use RE2 /safe_regex, so/v2/ordersdoes not match/v2/orders-archive. -
strip_path/prefix_rewrite/rewriteis confirmed against what the upstream actually expects — no version prefix leaks upstream. - Every version-header or content-negotiation route emits a
Varyheader naming only the low-cardinality routing header. - The
Accept/ version header is normalised to an allowlisted version token at the edge before it reaches any cache. - The default-version behaviour is explicit: either fail closed with
400or pin to the oldest supported major, never “latest”. - Inbound copies of internal version headers are stripped at the edge before routing logic runs.
- Unknown or unsupported version values are rejected with
400, not routed to a fallback backend. - The resolved version is echoed back to the client in a response header on every route.
- CDN hit ratio is monitored per version; a drop after enabling header versioning is treated as cache-key fragmentation.
FAQ
Which API versioning strategy is best enforced at the gateway?
URI/path versioning is the easiest to enforce and cache because the version is a literal segment the router matches with a radix tree, and it becomes part of the CDN cache key for free. Header and content-negotiation versioning keep URIs stable and support cleaner content negotiation, but require the gateway to route on a header value and the CDN to add that header to the cache key with a Vary directive. Most public APIs default to path versioning for CDN simplicity; internal service meshes often prefer header versioning to avoid URL churn.
How do I stop CDN cache fragmentation with header-based API versioning?
Emit an explicit Vary header naming only the version header (for example Vary: X-API-Version) so the CDN keys cache entries on that single header rather than the whole Accept string. Normalise the header value at the edge to a small allowlist of versions before it reaches the cache, and never let free-form Accept parameters or unbounded version tokens into the cache key, or hit ratio collapses as each unique header value creates a separate cache object.
Should the gateway apply a default version when no version is specified?
A default-version fallback is convenient but risky: it silently pins unversioned clients to whatever the gateway calls “default”, so promoting a new default can break every client that never sent a version. Prefer failing closed with a 400 for public APIs that require an explicit version, or pin the default to the oldest supported major and change it only on a published deprecation schedule. Always emit the resolved version back in a response header so clients can detect what they were routed to.
Can clients spoof the version header to reach an unintended backend?
Yes. Any client-supplied routing header must be treated as untrusted input. Strip inbound copies of internal version headers at the edge, validate the version value against an allowlist with a safe RE2 regex rather than a permissive pattern, and reject unknown versions with a 400 instead of falling through to a default. Never route to an internal-only or pre-release backend based solely on an unvalidated client header.
How does semantic versioning map onto these gateway strategies?
Only the major component of a semver string is routable — the gateway selects a backend per major, while minor and patch changes stay backward-compatible behind that major and never create new routes. The full mapping, including default-latest-minor resolution and deprecation hooks, is covered in semantic versioning for REST APIs at the gateway.
Related
- URI Path Versioning vs Accept-Header Versioning — head-to-head on the two most common strategies, with decision matrix and cache implications.
- Semantic Versioning for REST APIs at the Gateway — mapping major/minor/patch onto routable and non-routable version boundaries.
- Path & Header-Based Routing — the underlying match primitives (prefix, regex, header predicates) these strategies build on.
- Backward-Compatibility Contracts — keeping a major stable so everything routed to it honours one contract.
- Deprecation & Lifecycle Management — sunset headers and the schedule that governs default-version promotion.