Header-Based API Versioning — Accept Headers, Custom Headers & Content Negotiation
The API versioning problem#
Your API is a contract. Clients depend on its shape. When you need to change that shape — rename a field, restructure a response, deprecate an endpoint — you break that contract.
Versioning lets you evolve your API without breaking existing clients. The question is: where does the version live?
Three approaches to API versioning#
URL path versioning — /v1/users, /v2/users. Simple, visible, easy to route. But it leaks version concerns into every URL and makes it hard to version individual resources independently.
Query parameter versioning — /users?version=2. Keeps the URL clean-ish, but query parameters are for filtering data, not negotiating contracts.
Header-based versioning — the version lives in HTTP headers. The URL stays clean. Versions are negotiated through standard HTTP content negotiation. This is the approach we focus on here.
Accept header versioning#
The HTTP Accept header already exists for content negotiation. You can use vendor media types to encode the version:
GET /users HTTP/1.1
Accept: application/vnd.myapi.v2+json
The format follows the pattern: application/vnd.{vendor}.{version}+{format}
The server inspects the Accept header, extracts the version, and routes to the appropriate handler. If no version is specified, the server returns the default (usually latest stable) version.
HTTP/1.1 200 OK
Content-Type: application/vnd.myapi.v2+json
[{"id": 1, "full_name": "Alice Smith"}]
In v1, the field was name. In v2, it became full_name. Both versions coexist.
Custom version headers#
Some APIs prefer a dedicated custom header instead of overloading Accept:
GET /users HTTP/1.1
X-API-Version: 2
Or using a more standard-looking header:
GET /users HTTP/1.1
API-Version: 2024-03-29
Date-based versions (like Stripe uses) are especially effective. Each date represents a snapshot of the API contract. Clients pin to a date and upgrade on their own schedule.
Stripe's approach:
GET /v1/charges HTTP/1.1
Stripe-Version: 2024-06-20
This decouples the version from the resource structure entirely.
Content negotiation deep dive#
Full content negotiation involves multiple headers:
- Accept — the media type the client wants (e.g.,
application/vnd.myapi.v2+json) - Accept-Encoding — compression preferences (e.g.,
gzip) - Accept-Language — localization preferences
For API versioning, the Accept header does the heavy lifting. The server should return 406 Not Acceptable if the requested version does not exist:
GET /users HTTP/1.1
Accept: application/vnd.myapi.v99+json
HTTP/1.1 406 Not Acceptable
And it should include a Vary: Accept header so caches and proxies know that the response differs based on the Accept header:
HTTP/1.1 200 OK
Content-Type: application/vnd.myapi.v2+json
Vary: Accept
Version routing at the gateway#
In a microservices architecture, the API gateway is the natural place to handle version routing. The gateway inspects the version header and routes to the appropriate backend:
Version to service mapping:
- v1 requests route to
users-service-v1(legacy) - v2 requests route to
users-service-v2(current) - No version defaults to v2
In Kong, this looks like:
services:
- name: users-v1
url: http://users-service-v1:8080
routes:
- name: users-v1-route
paths: ["/users"]
headers:
X-API-Version: ["1"]
- name: users-v2
url: http://users-service-v2:8080
routes:
- name: users-v2-route
paths: ["/users"]
headers:
X-API-Version: ["2"]
In AWS API Gateway, you use request mapping templates to inspect headers and route to different Lambda functions or backend integrations.
Implementing in Express.js#
A middleware-based approach:
function versionRouter(handlers) {
return (req, res, next) => {
const accept = req.headers["accept"] || "";
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
const version = match ? parseInt(match[1]) : null;
// Fall back to custom header
const headerVersion = req.headers["x-api-version"];
const v = version || (headerVersion ? parseInt(headerVersion) : 2);
const handler = handlers[`v${v}`];
if (!handler) {
return res.status(406).json({ error: "Unsupported API version" });
}
handler(req, res, next);
};
}
app.get("/users", versionRouter({
v1: getUsersV1,
v2: getUsersV2,
}));
Implementing in Go#
func VersionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
version := r.Header.Get("X-API-Version")
if version == "" {
version = "2" // default
}
ctx := context.WithValue(r.Context(), "api-version", version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Version lifecycle management#
Every API version has a lifecycle:
- Preview — available for testing, not production-ready, may change without notice
- Stable — the recommended version for production use
- Deprecated — still functional, but clients should migrate; return
SunsetandDeprecationheaders - Retired — returns
410 Gone
Deprecation headers (RFC 8594):
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: <https://docs.myapi.com/migration-v3>; rel="successor-version"
Version transformation layer#
Instead of maintaining separate codebases for each version, use a transformation layer:
The current version is the canonical implementation. Older versions apply transformations to the canonical response:
// v2 is canonical
function transformV2ToV1(response) {
return response.map(user => ({
...user,
name: user.full_name, // v1 used "name"
full_name: undefined, // remove v2 field
}));
}
This keeps a single codebase and reduces maintenance burden. Stripe famously uses this pattern with their date-based versioning — each version is a set of transformations applied on top of the latest internal representation.
Testing versioned APIs#
Version-specific tests are critical:
- Contract tests — verify each version returns the expected schema
- Compatibility tests — ensure new versions do not break old client expectations
- Integration tests — test version routing at the gateway level
- Sunset tests — verify deprecated versions return proper headers
Common pitfalls#
No default version — if a client sends no version header, what happens? Always have a default. Some APIs default to the latest, others to the oldest stable. Choose a policy and document it.
Versioning too granularly — versioning every endpoint independently creates a combinatorial explosion. Version at the API level or resource level, not the field level.
Caching issues — if your CDN or proxy ignores the Vary header, clients may receive cached responses for the wrong version. Always set Vary: Accept or Vary: X-API-Version.
Forgetting CORS — if your API serves browsers, custom headers like X-API-Version require CORS preflight. Add them to Access-Control-Allow-Headers.
Header-based vs. URL-based: when to use which#
Use header-based when you want clean URLs, need fine-grained content negotiation, or follow RESTful principles strictly. Stripe, GitHub (v3), and Azure use this approach.
Use URL-based when simplicity matters most, your consumers are not HTTP-savvy, or you want versions to be visible in logs and bookmarks. Google APIs and most public REST APIs use this approach.
Many production APIs use a hybrid: major versions in the URL (/v2/users) and minor versions in headers (API-Version: 2024-03-29).
Visualize your API versioning strategy#
Map your API gateway, version routing, and backend service topology with Codelit. See how versions flow through your infrastructure.
Key takeaways#
- Accept header versioning uses vendor media types for standards-compliant content negotiation
- Custom headers like
X-API-Versionor date-based versions (Stripe-style) are simpler to implement - Route at the gateway — let Kong, AWS API Gateway, or Envoy handle version dispatch
- Transformation layers let you maintain one canonical implementation with version adapters
- Always set Vary headers so caches respect version differences
- Deprecation and Sunset headers give clients a migration timeline
- This is article #382 of our ongoing system design series
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
Related articles
API Backward Compatibility: Ship Changes Without Breaking Consumers
6 min read
api designBatch API Endpoints — Patterns for Bulk Operations, Partial Success, and Idempotency
8 min read
system designCircuit Breaker Implementation — State Machine, Failure Counting, Fallbacks, and Resilience4j
7 min read
Try these templates
Headless CMS Platform
Headless content management with structured content, media pipeline, API-first delivery, and editorial workflows.
8 componentsMultiplayer Game Backend
Real-time multiplayer game server with matchmaking, state sync, leaderboards, and anti-cheat.
8 componentsContent Moderation System
AI-powered content moderation with automated detection, human review queues, and appeals workflow.
9 componentsBuild this architecture
Generate an interactive architecture for Header in seconds.
Try it in Codelit →
Comments