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.
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
- 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
- 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
- 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
- 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"}
- 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"}
- 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:
- Announce: publish a migration guide and target dates
- Parallel run: support old and new versions side by side
- Signal: add Deprecation and Sunset headers; surface in response bodies and developer portal
- Enforce: after the sunset date, return 410 Gone or 406 with instructions
- 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
GraphQL to REST Migration: A Practical, Low‑Risk Guide
Step-by-step guide to migrate APIs from GraphQL to REST: design mapping, caching, auth, rollout, and pitfalls to avoid.
REST API Endpoint Naming: Conventions That Scale
A practical guide to naming REST API endpoints: principles, patterns, examples, and a checklist for clear, consistent, and evolvable URLs.
REST API Content Negotiation: A Practical Guide
Design, implement, and test REST API content negotiation: headers, q-values, media types, versioning, caching, and error handling with practical examples.