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:

  1. Normalise at the edge. Before the response is cached, rewrite Accept down to the single version token (or copy the parsed version into a dedicated X-Resolved-Version header) and Vary on that low-cardinality value instead of raw Accept.
  2. 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: Vary on 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: normalise Accept to a version token at the edge before caching.
  • Upstream 404s immediately after a path-versioning rollout. strip_path / prefix_rewrite mismatch — 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/orders as 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=9 somewhere unintended. Fix: allowlist versions and reject unknown values with a 400.

Validation

  • Path routes are anchored ($) and use RE2 / safe_regex; /v2/orders does not match /v2/orders-archive.
  • strip_path / prefix_rewrite is verified against the upstream’s real path expectations.
  • Every Accept-versioned response emits a Vary header, and each cache layer is confirmed to honour it.
  • Accept is 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