Header-Based API Versioning: A Practical Guide

A practical guide to header-based API versioning: patterns, negotiation, caching, examples, and rollout strategies for evolving contracts without breaking clients.

ASOasis
6 min read
Header-Based API Versioning: A Practical Guide

Image used for representation purposes only.

What Is Header-Based API Versioning?

Header-based versioning encodes the requested API version in HTTP headers rather than embedding it in the URL. Instead of calling /v2/customers/123, clients send the same resource path (/customers/123) with a header that signals the version they expect. Common patterns include:

  • A custom header (Api-Version: 2024-06-01)
  • An Accept header with a vendor media type (Accept: application/vnd.example.customer+json;version=2)
  • A Content-Type parameter for requests that send bodies (Content-Type: application/json;version=2)

This approach keeps resource identifiers stable, supports fine-grained evolution per resource or representation, and aligns with HTTP content negotiation.

Why Version At All?

Versioning is a strategy to evolve APIs without breaking existing clients. It lets you:

  • Introduce breaking changes carefully
  • Deprecate risky or insecure representations
  • Run parallel versions for staged migrations
  • Gather analytics on adoption before removing older forms

Even with rigorous backward compatibility, some changes (renamed fields, removed endpoints, different semantics) require a clean version boundary.

Where to Put the Version in Headers

  1. Custom header (recommended for clarity)
  • Request: Api-Version: 2 or Api-Version: 2024-06-01
  • Response: echo or advertise in headers like Content-Version: 2
  • Pros: Explicit, simple to route on at gateways
  • Cons: Not a built-in content negotiation mechanism; less tooling-aware than Accept
  1. Accept header (content negotiation)
  • Request: Accept: application/vnd.example.order+json;version=2
  • Response: Content-Type: application/vnd.example.order+json;version=2
  • Pros: Standards-aligned; granular per-representation
  • Cons: More complex to implement and document; easy to get caching wrong without Vary
  1. Content-Type parameter (for requests with bodies)
  • Use for POST/PUT/PATCH when the body’s schema changes
  • Pair with Accept for responses
  • Caveat: You’ll still need a way to version read-only GETs

Pragmatic guidance: pick one primary mechanism (custom header or Accept) and apply it consistently.

Designing the Version Identifier

  • Integer versions (v1, v2): easy to reason about; coarse-grained
  • Calendar versions (2024-06-01): communicate rollout date; easy for deprecation timelines; can be monotonic without implying SemVer
  • Semantic versions (1.2): useful if you want to expose non-breaking minor versions; avoid letting clients depend on minors unless you guarantee stability

Keep the meaning of the version clear: it identifies the contract of a representation, not your internal service build.

Request Handling and Negotiation Model

Decide early how strict the server is:

  • Strict: if the requested version is unknown, return 406 Not Acceptable (or 400) with guidance
  • Fallback: if no version is provided, serve a default (document it and log it)
  • Range or aliasing: allow clients to send version=2 and map to 2.3 behind the scenes; do not silently upgrade across breaking majors

Useful response headers:

  • Content-Version: 2024-06-01 (what you served)
  • Deprecation: true (or a date) to signal deprecation status
  • Sunset: Wed, 30 Sep 2026 23:59:59 GMT (planned removal date)
  • Link: https://developer.example.com/migrate ; rel=“deprecation”

Caching and CDN Correctness

Header-based versioning changes how intermediaries cache content. Get these right:

  • Vary: Api-Version when using a custom header
  • Vary: Accept when using media-type negotiation
  • ETag/Last-Modified: still apply per representation
  • Cache keys at your CDN/gateway must include the version header

Without proper Vary, caches can serve the wrong version to other clients.

Examples

  1. Custom header with calendar version (GET)
GET /customers/123 HTTP/1.1
Host: api.example.com
Api-Version: 2024-06-01
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Content-Version: 2024-06-01
Vary: Api-Version

{"id":"123","fullName":"Ada Lovelace","status":"active"}
  1. Accept header with vendor media type (POST)
POST /orders HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.order+json;version=2
Content-Type: application/vnd.example.order+json;version=2

{"sku":"ABC-123","qty":2}
HTTP/1.1 201 Created
Content-Type: application/vnd.example.order+json;version=2
Vary: Accept
Location: /orders/987

{"id":"987","sku":"ABC-123","qty":2,"state":"created"}
  1. Error for unsupported version
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
Link: <https://developer.example.com/migrate>; rel="help"

{"error":"unsupported_version","supported":["2023-12-01","2024-06-01"]}

Minimal Server-Side Routing Snippets

Node/Express middleware for a custom Api-Version header:

function parseApiVersion(req, _res, next) {
  const hdr = req.header('Api-Version');
  req.apiVersion = hdr || process.env.DEFAULT_API_VERSION; // log when defaulted
  next();
}

function routeByVersion(req, res, next) {
  switch (req.apiVersion) {
    case '2024-06-01':
      return v2Handler(req, res);
    case '2023-12-01':
      return v1Handler(req, res);
    default:
      return res.status(406).json({
        error: 'unsupported_version',
        supported: ['2023-12-01','2024-06-01']
      });
  }
}

NGINX routing by header (edge splitting):

map $http_api_version $api_upstream {
  default v1_pool;
  2024-06-01 v2_pool;
}

server {
  location / {
    proxy_set_header X-Served-Version $http_api_version;
    proxy_pass http://$api_upstream;
  }
}

Spring Boot filter sketch for Accept negotiation:

String accept = request.getHeader("Accept");
ApiVersion v = ApiVersion.fromAccept(accept);
request.setAttribute("apiVersion", v);
chain.doFilter(request, response);

Documenting Header-Based Versioning in OpenAPI

  • Represent the version header as a global parameter:
parameters:
  ApiVersionHeader:
    in: header
    name: Api-Version
    required: false
    schema:
      type: string
      enum: ["2023-12-01","2024-06-01"]
    description: Requested API contract version.
  • If using media-type negotiation, define vendor media types under content:
content:
  application/vnd.example.customer+json;version=2024-06-01:
    schema: { $ref: '#/components/schemas/CustomerV2' }
  application/vnd.example.customer+json;version=2023-12-01:
    schema: { $ref: '#/components/schemas/CustomerV1' }
  • Publish a separate, frozen spec per major version to simplify SDK generation

Pros and Cons vs URL or Query Parameters

Pros of header-based:

  • Clean, stable resource URLs; better cache keys when Vary is set
  • Enables per-representation versioning without multiplying routes
  • Plays well with HATEOAS and link relations

Cons:

  • Less discoverable in a browser or curl without docs
  • Can be mishandled by proxies/CDNs if Vary is missing
  • Some tooling and logs expect version in the path

URL-based pros/cons in brief: extremely discoverable and easy to route/log, but resource identity changes across versions and you risk hard-coded paths in clients.

Rollout and Deprecation Policy

Adopt a clear lifecycle with dates and headers:

  1. Announce: publish a migration guide and target dates
  2. Parallel run: support old and new versions side by side
  3. Signal: add Deprecation and Sunset headers; surface in response bodies and developer portal
  4. Enforce: after the sunset date, return 410 Gone or 406 with instructions
  5. Remove: delete the old handlers and schemas; keep an error shim for a grace window

Automate alerts: notify clients seen calling deprecated versions; include version in structured logs and analytics.

Testing and Quality Gates

  • Contract tests per version: validate request/response schemas and examples
  • Consumer-driven contracts: make sure every client’s expectations match the version they request
  • Golden recordings: capture representative traffic per version for regression testing
  • Shadow reads: compare responses from old and new versions during rollout
  • Load tests: ensure routing by header doesn’t degrade latency

Observability, Security, and Operations

  • Logs/metrics: record requested and served versions; build adoption dashboards
  • Rate limits: consider per-version quotas during migration
  • Error budgets: track version-specific SLOs when running in parallel
  • Security: treat version headers as untrusted input; validate against a whitelist
  • Capacity: avoid “version sprawl”; plan a maximum number of concurrently supported majors

Practical Checklist

  • Choose a single primary mechanism: Api-Version header or Accept vendor type
  • Define version identifiers and compatibility promise
  • Implement strict negotiation and clear defaults
  • Return Content-Version and proper Vary headers
  • Route by header at the gateway and in the app
  • Document in OpenAPI and publish migration guides
  • Emit Deprecation/Sunset and provide links to help
  • Monitor adoption and enforce sunsets

When Header-Based Shines

  • APIs with long-lived resource URLs and multiple client types
  • Teams that need per-representation evolution without path churn
  • Gateways/CDNs that can easily key caches by headers

Conclusion

Header-based versioning keeps your URLs stable and your contracts explicit. By picking a single, consistent mechanism, designing clear version identifiers, negotiating strictly, and getting Vary/caching right, you can evolve your API without surprising clients. Pair it with excellent documentation, observability, and a disciplined deprecation policy to keep your platform moving forward while protecting integrators and end users.

Related Posts