Semantic Versioning for REST APIs at the Gateway

Semantic versioning gives you a three-part number — MAJOR.MINOR.PATCH — with a precise contract: majors break, minors add, patches fix. The mistake teams make at the gateway is treating all three parts as routing signals, which explodes the route table and quietly breaks the semver promise that a minor bump is safe. The discipline that actually scales is narrow: route on the major, and only the major. Everything below the major is a backward-compatible deploy behind a stable route. This page shows how to encode that discipline in gateway configuration, how to resolve a bare major to the latest minor, and where to hang deprecation signalling.

Prerequisites

This builds on API versioning strategies, which covers the three placements of a version signal — path, header, and content negotiation — that you will map semver onto here. The parent Advanced Routing & API Versioning pillar covers the routing invariants, and path and header-based routing covers the match primitives. This page assumes you have already chosen a placement; it governs what you put in it.

The routable boundary: majors only

Semver’s contract maps cleanly onto routing if you respect one line: the major is the routable unit; minor and patch are not.

  • MAJOR — a breaking change. It gets its own routable version at the gateway: its own /v2/ prefix or X-API-Version: 2 value, pointing at its own upstream running the v2 code. Two majors coexist as two routes.
  • MINOR — backward-compatible additions. By contract, a v2 client tolerates any 2.x. So a minor bump is a deploy behind the existing v2 route, not a new route. The gateway keeps sending all v2 callers to it.
  • PATCH — backward-compatible fixes. Same as minor: deployed behind the major’s route, never routable.
Semver mapped to gateway routes: majors routable, minor and patch behind the route A client requests major version 2. The gateway has one route per major: a v1 route and a v2 route. The v2 route points at a single upstream cluster whose members run the latest minor and patch, shown as 2.5.0, 2.6.1, and 2.7.3 rolling forward over time. Minor and patch changes happen behind the stable v2 route and do not create new routes. A deprecation hook is attached to the v1 route emitting Sunset headers. Client asks: v2 Gateway route per MAJOR only v1 route Sunset header hook v2 route (stable) latest-minor resolve v2 upstream 2.5.0 → 2.6.1 → 2.7.3 (latest) minor/patch roll forward behind the same route

The payoff of this discipline is a route table that grows with breaking changes (rare, deliberate) rather than with every release (frequent). A service on 2.31.4 still has exactly one v2 route.

Encoding it: Kong 3.x

The major selects the route and the upstream; minors roll forward behind it. Note there is no 2.5 or 2.6 route — only v2:

# Kong 3.x — semver: one route per MAJOR, latest-minor behind it
_format_version: "3.0"

upstreams:
  - name: orders-v2-upstream        # targets always run the newest 2.x
    targets:
      - target: orders-v2-pods.internal:8080
        weight: 100

services:
  - name: orders-v2
    host: orders-v2-upstream         # -> latest deployed 2.x minor
    routes:
      - name: orders-v2
        paths: ["~/v2/orders(?:/.*)?$"]
        strip_path: true
    plugins:
      - name: response-transformer
        config:
          add:
            headers: ["X-API-Version:2.7.3"]   # echo the resolved full version

The X-API-Version echo carries the fully resolved 2.7.3, not the bare 2 the client asked for. That is default-latest-minor resolution in action: the client names the major, the gateway routes to the newest minor, and tells the client exactly what it got so the resolution is observable rather than magic. When you deploy 2.8.0, you update the upstream targets and the echoed header — no route change.

Encoding it: Envoy 1.32+

Envoy expresses the same shape as one route per major pointing at one cluster whose endpoints run the latest minor:

# Envoy 1.32+ — semver: MAJOR route -> latest-minor cluster
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.7.3" }   # resolved
        - match: { prefix: "/v1/orders" }
          route: { cluster: orders_v1, prefix_rewrite: "/orders" }
          response_headers_to_add:
            - header: { key: "x-api-version", value: "1.9.5" }
            - header: { key: "Deprecation", value: "true" }
            - header: { key: "Sunset", value: "Wed, 31 Dec 2026 23:59:59 GMT" }

The endpoints behind orders_v2 are managed via xDS EDS, so rolling from 2.6.1 to 2.7.3 is an endpoint update, not a RouteConfiguration change — the route stays stable across every minor and patch.

Default-latest-minor resolution

A well-behaved semver gateway lets a client ask for v2 and receive the newest compatible minor without naming it. This works only because minors are backward-compatible: a client written against 2.0 keeps working on 2.7 because 2.7 only added things. Implement it by pointing the major’s route at an upstream whose membership always runs the newest minor, and rolling minors forward behind that route. Always echo the resolved full version in a response header so a client (or your own logs) can see 2.7.3 even though the request said 2.

Resist the temptation to let clients pin a specific minor (2.5) through the gateway. Pinning minors reintroduces the route-table explosion you avoided and signals that your minors are not actually backward-compatible — which is a contract bug to fix in the service, not a routing feature to add at the edge. Where genuine within-major compatibility guarantees are needed, enforce them with backward-compatibility contracts rather than minor-level routes.

Deprecation hooks at the major boundary

Because only majors are retired, deprecation signalling lives on the major’s route. When v1 enters its sunset window, attach a response-header filter to the v1 route that emits Deprecation: true and a Sunset date (RFC 8594), while the route keeps serving traffic normally. Clients get a machine-readable countdown before the route is removed, and the signalling never touches per-request business logic. In Kong the same effect is a response-transformer plugin adding the two headers to the v1 route; in Envoy it is the response_headers_to_add block shown above. The full retirement workflow — grace periods, monitoring residual traffic, hard cutover — is covered under deprecation and lifecycle management.

Decision matrix

Semver component Routable at gateway? Gateway action Anti-pattern
MAJOR (breaking) Yes New route + new upstream; coexists with old major Reusing a major’s route for breaking changes
MINOR (additive) No Deploy behind existing major route; roll forward A /v2.5/ route or version=2.5 selector
PATCH (fix) No Deploy behind existing major route Any patch-level routing
Pre-release (-rc.1) Only via gated route Separate host or authenticated header Reachable from the public major selector
Build metadata (+build) Never Ignored for routing (semver precedence rule) Treating +build as a routing signal

Gotchas and failure signals

  • Route table growing every release. You are routing on minor or patch. Signal: dozens of 2.x routes. Fix: collapse to one route per major; move minors behind it.
  • A minor bump breaks clients. Your minor was not actually backward-compatible, so latest-minor resolution shipped a breaking change to unsuspecting v2 clients. Signal: error spikes right after a minor deploy with no route change. Fix: treat it as a contract violation — the change should have been a major.
  • Clients cannot tell what version they got. No resolved-version echo. Signal: support tickets that cannot pin down behaviour. Fix: emit X-API-Version with the full MAJOR.MINOR.PATCH.
  • Public clients reach a pre-release. A pre-release tag is routable from the public major selector. Signal: unfinished-version responses to normal traffic. Fix: gate pre-release routing behind a separate host or authenticated header.
  • Sunset date passes but traffic remains. Deprecation headers were emitted but residual v1 traffic was never monitored. Signal: 404/410 spike at cutover. Fix: monitor the deprecated route’s request rate throughout the sunset window before removal.

Validation

  • Exactly one gateway route exists per major version — no minor- or patch-level routes.
  • Minor and patch releases deploy behind the existing major route with no RouteConfiguration / route change.
  • The gateway echoes the fully resolved MAJOR.MINOR.PATCH in a response header on every route.
  • Default-latest-minor resolution points the major route at an upstream that always runs the newest minor.
  • Deprecated majors emit Deprecation and Sunset headers while still serving traffic.
  • Pre-release versions are reachable only through a gated route, never the public major selector.
  • Build metadata (+build) never affects routing decisions.
  • Residual traffic on a deprecated major is monitored throughout its sunset window before removal.

FAQ

Which parts of a semver string should the gateway route on?

Only the major. A breaking change increments the major and must be a separately routable version at the gateway — its own path prefix or version-header value pointing at its own upstream. Minor and patch increments are, by the semver contract, backward-compatible, so they must not create new routes; they are deployed behind the existing major and the gateway keeps routing all callers of that major to them transparently. Routing on minor or patch multiplies your route table and breaks the promise that a minor bump is non-breaking.

How does default-latest-minor resolution work at the gateway?

A client asks for a major (v2) and the gateway routes to the current latest minor within that major (say 2.7) without the client naming it. Because minors are backward-compatible, the caller sees only additive changes. Implement it by pointing the major’s route at an upstream or cluster whose members always run the newest minor, and rolling minors forward behind that stable route. Echo the fully resolved version (2.7.3) in a response header so clients can observe what they received.

Where do deprecation hooks fit in a semver gateway scheme?

Deprecation applies at the major boundary, because only majors are retired. When a major enters its sunset window, attach a plugin or response header filter to that major’s route that adds Deprecation and Sunset headers announcing the retirement date, while the route keeps serving traffic. This lets clients detect the deadline programmatically before the route is finally removed, and it keeps deprecation signalling out of the per-request business logic.

Should pre-release or build-metadata semver tags ever be routable?

Pre-release tags like 2.0.0-rc.1 can be routable but only through an explicit, access-controlled route, never via the public major selector. Build metadata (the +build suffix) must never affect routing — the semver spec says it is ignored for precedence, so treating it as a routing signal is a spec violation and a spoofing risk. Keep pre-release routing on a separate hostname or an authenticated header so a public client cannot reach an unfinished version by guessing a tag.


Parent: API Versioning Strategies