Loading learning content...
Having established why versioning is essential, we now face a practical question: how do you actually implement version management in your API?
Several well-established strategies exist, each with distinct characteristics, tradeoffs, and ideal use cases. There's no universally 'correct' approach—the right choice depends on your API's nature, your consumers' needs, and your operational constraints.
This page provides a comprehensive analysis of each major strategy, examining:
By the end, you'll be equipped to make an informed versioning decision for any API you design.
We'll examine: (1) URL Path Versioning — the most visible and widely adopted approach, (2) Query Parameter Versioning — a simple but limited alternative, (3) Header Versioning — a 'pure' but less discoverable approach, and (4) Content Negotiation — the most sophisticated but complex option. Each has legitimate uses depending on context.
URL Path Versioning embeds the version number directly in the URL path, typically as a prefix segment:
https://api.example.com/v1/users/123
https://api.example.com/v2/users/123
https://api.example.com/v3/users/123
This is the most widely adopted versioning strategy, used by major APIs including Twitter, GitHub, Stripe, and Google Cloud. Its popularity stems from several advantageous characteristics.
How It Works:
The version identifier becomes part of the resource address. Clients specify which version they want simply by constructing the appropriate URL. The server routes requests based on the URL path.
From an implementation perspective, this can be handled through:
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// Express.js URL Path Versioning Exampleimport express from 'express'; const app = express(); // Version 1 API routesconst v1Router = express.Router();v1Router.get('/users/:id', (req, res) => { // V1 returns a simple user object const user = { id: req.params.id, name: "John Doe", email: "john@example.com" }; res.json(user);}); // Version 2 API routesconst v2Router = express.Router();v2Router.get('/users/:id', (req, res) => { // V2 returns a richer, restructured response const user = { id: req.params.id, profile: { displayName: "John Doe", avatar: "https://cdn.example.com/avatars/123.jpg" }, contact: { email: "john@example.com", emailVerified: true }, metadata: { createdAt: "2024-01-15T10:30:00Z", updatedAt: "2024-06-20T14:22:00Z" } }; res.json(user);}); // Mount versioned routersapp.use('/v1', v1Router);app.use('/v2', v2Router); // Requests to /v1/users/123 get V1 format// Requests to /v2/users/123 get V2 formatReal-World Usage:
/repos/octocat/Hello-World pattern with version communicated separately, but moved toward path versioning for newer APIs/2/tweets path structurehttps://api.stripe.com/v1/charges for core versioningWhen to Choose URL Path Versioning:
Query Parameter Versioning places the version identifier as a query parameter:
https://api.example.com/users/123?version=1
https://api.example.com/users/123?v=2
https://api.example.com/users/123?api-version=2024-01-15
This approach treats version as a parameter rather than a structural part of the URL. While less common than URL path versioning, it has specific use cases where it shines.
How It Works:
The version parameter is parsed from the query string on each request. If absent, the server typically defaults to either the latest version or a specified default. The same endpoint handler may serve multiple versions, switching behavior based on the parameter.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// Express.js Query Parameter Versioningimport express from 'express'; const app = express(); // Version resolution middlewareconst resolveApiVersion = (req: express.Request, res: express.Response, next: express.NextFunction) => { const version = req.query.version || req.query.v || req.query['api-version']; // Default to latest stable version if not specified req.apiVersion = version ? parseInt(version as string) : 2; // Validate version const supportedVersions = [1, 2, 3]; if (!supportedVersions.includes(req.apiVersion)) { return res.status(400).json({ error: 'UNSUPPORTED_VERSION', message: `Version ${req.apiVersion} is not supported. Supported: ${supportedVersions.join(', ')}`, suggestedVersion: 2 }); } next();}; app.use(resolveApiVersion); // Single endpoint handles all versionsapp.get('/users/:id', (req, res) => { const userId = req.params.id; switch (req.apiVersion) { case 1: // V1: Simple flat structure return res.json({ id: userId, name: "John Doe", email: "john@example.com" }); case 2: // V2: Nested structure with more fields return res.json({ id: userId, profile: { displayName: "John Doe", email: "john@example.com" }, createdAt: "2024-01-15T10:30:00Z" }); case 3: // V3: HATEOAS links, richer metadata return res.json({ data: { id: userId, type: "user", attributes: { displayName: "John Doe", email: "john@example.com" } }, links: { self: `/users/${userId}?version=3`, posts: `/users/${userId}/posts?version=3` } }); }}); // Requests:// GET /users/123?version=1 → V1 response// GET /users/123?v=2 → V2 response// GET /users/123 → V2 response (default)A critical design decision with query parameter versioning is the default behavior. If you default to 'latest' and a client doesn't specify a version, they'll silently receive a new version when you release it—potentially breaking. Always default to a specific, stable version and require explicit opt-in for new versions.
Real-World Usage:
?Version=2012-12-01 format with date-based versions?api-version=2023-05-01 pattern?v=2 for simpler version specificationWhen to Choose Query Parameter Versioning:
Header Versioning communicates the version through a custom HTTP header:
GET /users/123 HTTP/1.1
Host: api.example.com
X-API-Version: 2
---
GET /users/123 HTTP/1.1
Host: api.example.com
Api-Version: 2024-01-15
This approach keeps URLs completely clean of version information, placing versioning in the metadata layer of HTTP. It's considered more 'RESTfully pure' by some practitioners.
How It Works:
The server reads a custom header on each request to determine which version of the API to invoke. If the header is missing, the server may return an error, use a default, or prompt the client to specify.
Common header names include:
X-API-Version (the 'X-' prefix is technically deprecated but widely used)Api-Version (cleaner, modern approach)Accept-Version (mirrors content negotiation patterns)1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// Express.js Header Versioningimport express from 'express'; const app = express(); // CORS configuration to allow custom version headerapp.use((req, res, next) => { res.header('Access-Control-Allow-Headers', 'Api-Version, Content-Type, Authorization'); res.header('Access-Control-Expose-Headers', 'Api-Version'); next();}); // Version extraction middlewareconst extractApiVersion = (req: express.Request, res: express.Response, next: express.NextFunction) => { // Check multiple possible header names const versionHeader = req.headers['api-version'] || req.headers['x-api-version'] || req.headers['accept-version']; if (!versionHeader) { return res.status(400).json({ error: 'VERSION_REQUIRED', message: 'Api-Version header is required', example: 'Api-Version: 2', supportedVersions: ['1', '2', '3'] }); } const version = parseInt(versionHeader as string); if (isNaN(version) || version < 1 || version > 3) { return res.status(400).json({ error: 'INVALID_VERSION', message: `Version '${versionHeader}' is not valid`, supportedVersions: ['1', '2', '3'] }); } req.apiVersion = version; // Echo version back in response for clarity res.header('Api-Version', version.toString()); next();}; app.use(extractApiVersion); // Versioned endpointapp.get('/users/:id', (req, res) => { const userId = req.params.id; const version = req.apiVersion; // Set Vary header for caching res.header('Vary', 'Api-Version'); // Version-specific response logic if (version === 1) { return res.json({ id: userId, name: "John Doe", email: "john@example.com" }); } if (version === 2) { return res.json({ user: { id: userId, displayName: "John Doe", email: "john@example.com", createdAt: "2024-01-15T10:30:00Z" } }); } // Version 3 return res.json({ data: { type: "user", id: userId, attributes: { displayName: "John Doe", email: "john@example.com" } }, meta: { requestedVersion: 3 } });}); // Client usage with curl:// curl -H "Api-Version: 2" https://api.example.com/users/123When using header versioning, always include Vary: Api-Version (or your header name) in responses. This tells caches that responses differ based on this header, preventing cache poisoning where one version's response is incorrectly served to a client requesting another version.
Real-World Usage:
X-GitHub-Api-Version header (now moving toward other approaches)Stripe-Version header alongside other mechanismsapi-version header for version specificationWhen to Choose Header Versioning:
Content Negotiation Versioning uses the standard HTTP Accept header with a custom media type that includes version information:
GET /users/123 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json
---
GET /users/123 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.user-v2+json
This approach leverages HTTP's built-in content negotiation mechanism, treating different API versions as different representations of the same resource—exactly what content negotiation was designed for.
How It Works:
The Accept header uses a vendor media type (indicated by vnd.) that embeds version information. The server parses this media type and returns the appropriate version. The response Content-Type header echoes the version served.
Media type formats vary:
application/vnd.company.v2+json — Version in media typeapplication/vnd.company+json; version=2 — Version as parameterapplication/vnd.company.resource.v2+json — Resource-specific versioning123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
// Express.js Content Negotiation Versioningimport express from 'express'; const app = express(); interface MediaTypeInfo { vendor: string; version: number; format: string;} // Parse vendor media type to extract versionfunction parseVendorMediaType(accept: string): MediaTypeInfo | null { // Matches: application/vnd.example.v2+json // or: application/vnd.example+json; version=2 const patterns = [ // vnd.company.v2+json format /application\/vnd\.([a-z]+)\.v(\d+)\+([a-z]+)/i, // vnd.company+json; version=2 format /application\/vnd\.([a-z]+)\+([a-z]+);\s*version=(\d+)/i, ]; for (const pattern of patterns) { const match = accept.match(pattern); if (match) { if (pattern === patterns[0]) { return { vendor: match[1], version: parseInt(match[2]), format: match[3] }; } else { return { vendor: match[1], version: parseInt(match[3]), format: match[2] }; } } } return null;} // Content negotiation middlewareconst negotiateContent = (req: express.Request, res: express.Response, next: express.NextFunction) => { const accept = req.headers.accept || ''; // Check for vendor media type const mediaInfo = parseVendorMediaType(accept); if (!mediaInfo) { // Check if they specified any Accept header if (accept && !accept.includes('*/*') && !accept.includes('application/json')) { return res.status(406).json({ error: 'NOT_ACCEPTABLE', message: 'Unsupported media type. Use: application/vnd.example.v{VERSION}+json', supportedMediaTypes: [ 'application/vnd.example.v1+json', 'application/vnd.example.v2+json', 'application/vnd.example.v3+json' ] }); } // Default to latest version with JSON req.apiVersion = 3; req.responseFormat = 'json'; } else { if (mediaInfo.vendor !== 'example') { return res.status(406).json({ error: 'UNKNOWN_VENDOR', message: `Unknown vendor: ${mediaInfo.vendor}` }); } if (mediaInfo.version < 1 || mediaInfo.version > 3) { return res.status(406).json({ error: 'UNSUPPORTED_VERSION', message: `Version ${mediaInfo.version} is not supported`, supportedVersions: [1, 2, 3] }); } req.apiVersion = mediaInfo.version; req.responseFormat = mediaInfo.format; } next();}; app.use(negotiateContent); // Versioned endpointapp.get('/users/:id', (req, res) => { const userId = req.params.id; const version = req.apiVersion; // Set Content-Type to reflect negotiated version res.header('Content-Type', `application/vnd.example.v${version}+json`); res.header('Vary', 'Accept'); switch (version) { case 1: return res.json({ id: userId, name: "John Doe", email: "john@example.com" }); case 2: return res.json({ user: { id: userId, profile: { displayName: "John Doe" }, contact: { email: "john@example.com" } } }); case 3: return res.json({ type: "user", id: userId, attributes: { displayName: "John Doe", email: "john@example.com" }, links: { self: `/users/${userId}` } }); }}); // Client usage:// curl -H "Accept: application/vnd.example.v2+json" https://api.example.com/users/123Real-World Usage:
application/vnd.github.v3+json formatWhen to Choose Content Negotiation:
With all four strategies covered, let's compare them systematically across key dimensions to help inform your decision:
| Dimension | URL Path | Query Param | Header | Content Neg. |
|---|---|---|---|---|
| Visibility | ★★★★★ | ★★★☆☆ | ★★☆☆☆ | ★☆☆☆☆ |
| Browser Testing | ★★★★★ | ★★★★★ | ★☆☆☆☆ | ★☆☆☆☆ |
| Caching Simplicity | ★★★★★ | ★★★☆☆ | ★★☆☆☆ | ★★☆☆☆ |
| URL Cleanliness | ★★☆☆☆ | ★★★☆☆ | ★★★★★ | ★★★★★ |
| REST Purity | ★★☆☆☆ | ★★★☆☆ | ★★★★☆ | ★★★★★ |
| Implementation Complexity | ★★★★☆ | ★★★★★ | ★★★☆☆ | ★★☆☆☆ |
| CORS Friendliness | ★★★★★ | ★★★★★ | ★★★☆☆ | ★★★☆☆ |
| Resource-level Versioning | ★☆☆☆☆ | ★★☆☆☆ | ★★★☆☆ | ★★★★★ |
| Wide Adoption | ★★★★★ | ★★★★☆ | ★★★☆☆ | ★★☆☆☆ |
For most production APIs, URL Path Versioning is the pragmatic default. Its visibility, tooling compatibility, and simplicity outweigh the theoretical benefits of 'purer' approaches. Choose alternatives only when you have specific requirements that URL versioning doesn't meet.
Decision Framework:
Choose URL Path when:
Choose Query Parameter when:
Choose Header when:
Choose Content Negotiation when:
In practice, many APIs use hybrid approaches or variations that don't fit neatly into the four categories above.
Date-Based Versioning:
Some APIs use dates instead of numbers:
/users/123?api-version=2024-01-15
Stripe-Version: 2024-01-15
This approach ties versions to release dates, making it clear when the version was released and enabling frequent releases without confusing version number inflation. Stripe pioneered this approach.
Pin to Version + Default Latest:
Some APIs let you 'pin' a version but default to latest:
# Account setting locks API version
Stripe-Version: 2024-01-15
# Or, without setting, you get the latest
This reduces friction for new integrators while protecting existing ones.
Multi-Strategy Support:
Some APIs support multiple versioning mechanisms simultaneously:
# URL version
/v2/users/123
# OR Query parameter
/users/123?version=2
# OR Header
Api-Version: 2
The server uses precedence rules: Header > Query > URL (or similar). This provides flexibility but adds implementation complexity.
GraphQL Versioning:
GraphQL APIs challenge traditional versioning because the query language allows clients to request exactly what they need. GraphQL philosophy suggests:
This works well when you control client and server, but may not suit all contexts.
Stripe's approach deserves special attention: they use date-stamped versions (e.g., 2024-01-15) set via header, with dashboard configuration to pin your account's default version. When you upgrade, you see exactly what changed since your pinned date. This model enables continuous API evolution without traditional major version bumps.
We've covered the full landscape of API versioning strategies. Let's consolidate the key insights:
What's Next:
Knowing how to version is only half the challenge. The next page addresses a critical question: what changes require a new version? We'll explore the crucial distinction between breaking and non-breaking changes—the classification that determines whether you can evolve within a version or must bump to a new one.
You now understand the major API versioning strategies and their tradeoffs. Each approach has valid use cases, but for most APIs, URL Path Versioning provides the best balance of simplicity, visibility, and tooling compatibility. Next, we'll explore what kinds of changes require version bumps.