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 Accept header 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 the Accept header 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.

Three API versioning strategies compared at the gateway Three horizontal lanes. Top lane: URI path versioning, where the client sends GET /v2/orders/42 and the gateway matches on a path prefix, routing to the v2 upstream; the version is part of the CDN cache key automatically. Middle lane: header versioning, where the client sends GET /orders/42 with header X-API-Version 2 and the gateway matches on the header value, requiring a Vary header for caching. Bottom lane: content negotiation, where the client sends GET /orders/42 with Accept application/vnd.api+json version 2 and the gateway parses the media-type parameter, needing careful Vary and normalisation. Client request Gateway matches on Upstream + cache URI / path GET /v2/orders/42 version in the path path prefix /v2/ radix-tree, O(1) orders-v2 cached by URL free Version header GET /orders/42 X-API-Version: 2 header value = 2 exact / safe_regex orders-v2 needs Vary header Content neg. GET /orders/42 Accept: …vnd.api+json; version=2 Accept param parse media-type orders-v2 Vary + normalise

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/orders does not match /v2/orders-archive.
  • strip_path / prefix_rewrite / rewrite is confirmed against what the upstream actually expects — no version prefix leaks upstream.
  • Every version-header or content-negotiation route emits a Vary header 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 400 or 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.



← Advanced Routing & API Versioning