API Gateway Patterns

API Versioning Through the Gateway: URI, Header, and Media Type

How to implement API versioning at the gateway layer — routing by version prefix, Accept header version negotiation, and managing multiple backend versions simultaneously.

Why Version APIs?

APIs evolve. You add fields, change data types, remove deprecated endpoints, or restructure response shapes. Without versioning, any breaking change instantly breaks every client using the API. With versioning, you can introduce breaking changes in a new version while keeping the old version alive for clients that have not yet migrated.

The API gateway is the natural place to implement versioning strategy because it controls routing — the gateway can direct requests for different versions to different backend deployments, or to the same backend with version-specific transformation applied.

Versioning Strategies

URI Path Versioning

The most common and unambiguous approach: embed the version in the URL path.

GET /v1/users/123     → v1 behavior (returns {user_id, name, email})
GET /v2/users/123     → v2 behavior (returns {id, display_name, email, created_at})

URI versioning is explicit, cacheable (URL uniquely identifies the resource and its version), and easy to test in a browser or with curl. The downside: it violates REST principles (the URI should identify the resource, not the API version) and makes the URL slightly less clean.

Custom Header Versioning

Pass the version in a custom request header:

GET /users/123
X-API-Version: 2

Header versioning keeps URLs clean but makes the API less explorable — developers cannot test it in a browser by navigating to a URL. It also requires all clients to know to include the header. Cache behavior requires Vary: X-API-Version to prevent v1 responses from being served to v2 clients.

Accept Header (Media Type) Versioning

Version via the Accept header using custom media types — the REST-purist approach favored by GitHub's API:

GET /users/123
Accept: application/vnd.example.v2+json

GET /users/123
Accept: application/vnd.example.v1+json

This aligns with HTTP content negotiation semantics. The server returns the resource representation the client requested. It is the most technically correct approach but the hardest to implement and communicate to API consumers. Requires Vary: Accept for correct cache behavior.

Query Parameter Versioning

GET /users/123?version=2

Avoid query parameter versioning. Query parameters are typically reserved for filtering, sorting, and pagination — not for changing the fundamental shape of the response. Many caching proxies also strip or ignore query parameters.

Gateway Routing by Version

URI Path Routing

# Kong: route /v1/* and /v2/* to different services
services:
  - name: users-service-v1
    url: http://users-v1.internal:8080
    routes:
      - paths: ["/v1/users"]
        strip_path: false  # keep /v1/ prefix for upstream versioning

  - name: users-service-v2
    url: http://users-v2.internal:8080
    routes:
      - paths: ["/v2/users"]
        strip_path: false
# Envoy: version-aware routing
routes:
  - match:
      prefix: "/v2/"
    route:
      cluster: users_v2_cluster
      prefix_rewrite: "/"  # strip version prefix before forwarding
  - match:
      prefix: "/v1/"
    route:
      cluster: users_v1_cluster
      prefix_rewrite: "/"

Header-Based Version Routing

# Envoy: route by custom version header
routes:
  - match:
      prefix: "/users"
      headers:
        - name: x-api-version
          string_match:
            exact: "2"
    route:
      cluster: users_v2_cluster
  - match:
      prefix: "/users"   # no version header → default to v1
    route:
      cluster: users_v1_cluster

Same Backend, Gateway-Applied Transformation

If the backend supports only one version, the gateway can translate older version requests into the current format:

# Gateway plugin: translate v1 request to v2 format
def transform_v1_to_v2(request_body: dict) -> dict:
    """Map v1 field names to v2 field names."""
    return {
        'display_name': request_body.get('name'),         # renamed
        'email': request_body.get('email'),
        'metadata': request_body.get('extra_fields', {}), # restructured
    }

def transform_v2_response_to_v1(response_body: dict) -> dict:
    """Map v2 response back to v1 format for old clients."""
    return {
        'user_id': response_body['id'],                    # renamed back
        'name': response_body['display_name'],
        'email': response_body['email'],
    }

Default Version Policy

Always Require an Explicit Version

Do not route unversioned requests to "latest." Unversioned requests become a hidden dependency on the current version — when you release v3, all unversioned clients that expected v2 behavior will break silently.

Return 400 for unversioned requests with a clear error:

{
  "error": "version_required",
  "message": "Please specify an API version. Use /v2/ prefix or X-API-Version header.",
  "supported_versions": ["v1", "v2"],
  "latest_version": "v2",
  "docs": "https://docs.example.com/api/versioning"
}

Deprecation Warnings

When a client uses a deprecated version, add a deprecation header to the response. This is visible to developers monitoring their API calls without breaking clients:

HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jan 2025 00:00:00 GMT
Link: <https://docs.example.com/api/v2>; rel="successor-version"
Warning: 299 - "This API version is deprecated and will be removed on 2025-01-01."

The Sunset header (RFC 8594) formally signals when the version will be decommissioned. Clients that parse it can automate deprecation alerting.

Multi-Version Management

Shared vs Separate Backends

ApproachWhen to UseTrade-offs
Separate backend deploymentsMajor breaking changes requiring different code pathsFull isolation, higher operational cost
Single backend with version flagMinor differences handled via conditional logicLower cost, risk of entangled code
Gateway transformationOld clients needing old format, new backend onlyLow cost, transformation complexity

Version Lifecycle

v1: active → deprecated (Sunset header added) → sunset (removed)
v2: development → active → deprecated → sunset
v3: development → active

Overlap period (v1 deprecated, v2 active): minimum 6-12 months

Monitor usage of deprecated versions via gateway analytics before sunsetting:

# Grafana query: deprecated API version usage
sum by(consumer_id) (
    rate(gateway_requests_total{route="users-v1"}[1d])
) > 0

Reach out to teams with non-zero v1 usage before the sunset date. Do not sunset a version until all known consumers have migrated.

AWS API Gateway Stage Variables

AWS API Gateway uses "stages" as a versioning mechanism — {api-id}.execute-api.amazonaws.com/{stage}/:

{
  "x-amazon-apigateway-integration": {
    "uri": "http://${stageVariables.upstreamUrl}/users",
    "type": "HTTP_PROXY"
  }
}

Each stage (v1, v2, prod, beta) has its own upstreamUrl variable, routing to the appropriate backend. Custom domain names (api.example.com) are mapped to specific stages, hiding the AWS URL structure from clients.

Summary

URI path versioning (/v1/, /v2/) is the most practical and widely adopted approach — explicit, cacheable, and easy for developers to understand. Route different versions to different backends at the gateway level, or use gateway transformation to support old clients against a single backend. Always require explicit version selection. Communicate deprecation with Deprecation, Sunset, and Link headers well in advance, monitor usage, and never sunset a version with active consumers.

Related Protocols

Related Glossary Terms

More in API Gateway Patterns