Loading learning content...
While URL versioning dominates the public API landscape, a powerful alternative exists: header-based versioning. In this approach, the version is transmitted via HTTP headers rather than embedded in the URL path. The same URL—https://api.example.com/users—can return different responses depending on which version the client requests through headers.
Header versioning aligns more closely with REST architectural principles, which dictate that URLs should identify resources, not representations. It enables finer-grained version control, supports content negotiation, and provides a path for gradual migrations that URL versioning cannot easily achieve.
In this page, we'll explore the various header versioning strategies, understand their implementation patterns, and learn when header versioning offers advantages over URL-based approaches.
By the end of this page, you will understand the three main header versioning approaches (custom header, Accept header, media type), know how to implement each pattern, and be able to evaluate when header versioning is the superior choice for your API architecture.
Header versioning addresses several limitations of URL versioning and provides unique capabilities:
Resource Identity Preservation
In REST architecture, a URL should uniquely identify a resource. With URL versioning, /v1/users/123 and /v2/users/123 appear to be different resources, when in fact they represent the same user—just with different response formats. Header versioning keeps the URL as /users/123 while the header specifies how that resource should be represented.
Finer-Grained Evolution
URL versioning typically works at major version granularity. Header versioning can support micro-versions, date-based versions, or feature flags that would be unwieldy in URLs.
Content Negotiation Compatibility
HTTP already has a mechanism for negotiating response formats: the Accept header. Media type versioning extends this existing pattern rather than inventing a new one.
Cleaner URLs
Without version prefixes, URLs are shorter, more readable, and more shareable. Documentation and examples don't become stale when versions change.
| Aspect | URL Versioning | Header Versioning |
|---|---|---|
| URL structure | /v1/users/123 | /users/123 |
| Version location | Path segment | HTTP header |
| REST compliance | Violates resource identity | Preserves resource identity |
| Discoverability | Immediately visible | Requires documentation |
| Browser testing | Paste URL directly | Requires header manipulation |
| Caching | Automatic (different URLs) | Requires Vary header |
| Granularity | Major versions | Any granularity |
| Client complexity | URL change | Header change |
Header and URL versioning aren't mutually exclusive. Many successful APIs use URL versioning for major version changes (v1 → v2) while using header versioning for micro-versioning within a major version. This hybrid approach captures the benefits of both.
The most straightforward header versioning approach uses a custom HTTP header to specify the desired API version. This is simple to implement, easy to understand, and widely supported.
Common Custom Header Patterns
X-API-Version: 2 — Simple numeric versionApi-Version: 2.1 — Major.minor formatAPI-Version: 2023-10-16 — Date-based (used by Stripe, Azure)X-App-Version: 1.2.3 — Semantic versioningNote: The X- prefix for custom headers is deprecated by RFC 6648 but still widely used. Modern APIs often omit it.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Custom Header Versioning Implementation import express, { Request, Response, NextFunction } from 'express'; interface VersionedRequest extends Request { apiVersion: string; apiVersionDate?: Date;} // Version extraction middlewarefunction versionMiddleware(req: VersionedRequest, res: Response, next: NextFunction) { // Check for version header (try multiple common names) const versionHeader = req.headers['api-version'] || req.headers['x-api-version'] || req.headers['accept-version']; if (!versionHeader) { // No version specified - use default req.apiVersion = DEFAULT_VERSION; res.setHeader('X-API-Version-Used', DEFAULT_VERSION); res.setHeader('X-API-Version-Warning', 'No version specified; using default. Specify version for stability.'); } else { req.apiVersion = String(versionHeader); res.setHeader('X-API-Version-Used', req.apiVersion); } // Validate version exists if (!SUPPORTED_VERSIONS.includes(req.apiVersion)) { return res.status(400).json({ error: 'Unsupported API version', requestedVersion: req.apiVersion, supportedVersions: SUPPORTED_VERSIONS, latestVersion: LATEST_VERSION, }); } // Check for deprecated version if (DEPRECATED_VERSIONS.includes(req.apiVersion)) { const sunset = getSunsetDate(req.apiVersion); res.setHeader('Deprecation', 'true'); res.setHeader('Sunset', sunset); res.setHeader('Link', `</api/upgrade>; rel="deprecation"; type="text/html"`); } next();} const app = express();app.use(versionMiddleware); // Example client request:// GET /users/123// Headers:// API-Version: 2023-10-16// Authorization: Bearer token123 const SUPPORTED_VERSIONS = ['2022-01-01', '2022-06-15', '2023-01-01', '2023-10-16'];const DEPRECATED_VERSIONS = ['2022-01-01'];const DEFAULT_VERSION = '2023-01-01';const LATEST_VERSION = '2023-10-16';Date-Based Versioning (The Stripe Model)
Stripe pioneered a powerful pattern: using dates as version identifiers. Instead of opaque version numbers, dates communicate exactly when an API behavior was introduced while providing natural chronological ordering.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Stripe-Style Date-Based Versioning // Stripe uses a fixed URL path (/v1) combined with date-based header versioning// Request example:// POST /v1/charges// Headers:// Stripe-Version: 2023-10-16// Authorization: Bearer sk_test_xxx interface StripeVersionConfig { version: string; // Date string: YYYY-MM-DD changes: VersionChange[]; // What changed in this version defaultFor: string[]; // Account creation date ranges} const VERSION_HISTORY: StripeVersionConfig[] = [ { version: '2023-10-16', changes: [ { endpoint: 'charges', change: 'Added `application_fee_amount` field' }, { endpoint: 'customers', change: 'Email validation now stricter' }, ], defaultFor: ['2023-10-16', 'present'], }, { version: '2023-08-16', changes: [ { endpoint: 'subscriptions', change: 'Changed proration behavior' }, ], defaultFor: ['2023-08-16', '2023-10-15'], }, { version: '2023-05-15', changes: [ { endpoint: 'invoices', change: 'New line item structure' }, ], defaultFor: ['2023-05-15', '2023-08-15'], },]; // Version resolutionfunction resolveVersion(headers: Headers, account: Account): string { // 1. Explicit header takes precedence const explicitVersion = headers['stripe-version']; if (explicitVersion) { validateVersion(explicitVersion); return explicitVersion; } // 2. Fall back to account's pinned version if (account.pinnedApiVersion) { return account.pinnedApiVersion; } // 3. Fall back to account's creation-date default return getDefaultVersionForDate(account.createdAt);} // Benefits of date-based versioning:// - Self-documenting: "2023-10-16" is clearer than "v47"// - Chronological ordering is intuitive// - Easy to correlate with changelogs// - Supports account-level version pinningStripe's genius is that each account has a default API version set at creation time. This means new accounts automatically get the latest version, while existing accounts remain on their original version until explicitly upgraded. This prevents breaking changes from affecting existing integrations while keeping new integrations on current APIs.
HTTP's built-in content negotiation mechanism uses the Accept header for clients to express preferences about response format. Accept header versioning extends this pattern to include version information, which is arguably the most RESTful approach.
Standard Accept Header Format
Accept: application/vnd.example.v2+json
The format follows the pattern:
application/vnd.{vendor}.{version}+{format}
vnd. — Vendor-specific media type prefix{vendor} — Your company or API identifier{version} — Version identifier+{format} — Underlying format (json, xml, etc.)123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Accept Header Versioning Implementation import express, { Request, Response, NextFunction } from 'express'; interface VersionedRequest extends Request { apiVersion: number; responseFormat: string;} // Media type patterns for version extractionconst MEDIA_TYPE_PATTERN = /^application\/vnd\.example\.v(\d+)\+(json|xml)$/;const SIMPLE_VERSION_PATTERN = /^application\/vnd\.example-(\d+)\+json$/; function acceptVersionMiddleware( req: VersionedRequest, res: Response, next: NextFunction) { const accept = req.headers['accept'] || 'application/json'; // Try to parse versioned media type const match = accept.match(MEDIA_TYPE_PATTERN); if (match) { req.apiVersion = parseInt(match[1], 10); req.responseFormat = match[2]; // Set Content-Type to match the versioned media type res.setHeader('Content-Type', `application/vnd.example.v${req.apiVersion}+${req.responseFormat}`); } else if (accept.includes('application/json')) { // Standard JSON request - use default version req.apiVersion = DEFAULT_VERSION; req.responseFormat = 'json'; res.setHeader('Content-Type', 'application/json'); res.setHeader('X-API-Default-Version', 'true'); } else { // Unsupported media type return res.status(406).json({ error: 'Not Acceptable', message: 'Requested media type not supported', supportedTypes: [ 'application/vnd.example.v1+json', 'application/vnd.example.v2+json', 'application/vnd.example.v3+json', 'application/json (defaults to v2)', ], }); } next();} const app = express();app.use(acceptVersionMiddleware); // Example requests:// Version 1:// GET /users/123// Accept: application/vnd.example.v1+json//// Version 2:// GET /users/123// Accept: application/vnd.example.v2+json//// Version 3 (XML):// GET /users/123// Accept: application/vnd.example.v3+xml const DEFAULT_VERSION = 2;GitHub's Accept Header Approach
GitHub's API uses Accept header versioning with a slightly different format, demonstrating real-world adoption:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// GitHub-Style Accept Header Versioning // GitHub's media type format:// application/vnd.github.v3+json (current)// application/vnd.github.v3.raw+json (raw content)// application/vnd.github.v3.html+json (HTML formatted)// application/vnd.github.v3.full+json (full response) interface GitHubAcceptParts { version: string; format: 'json' | 'html' | 'raw' | 'full'; subtype?: string;} function parseGitHubAccept(accept: string): GitHubAcceptParts | null { // Pattern: application/vnd.github.{version}[.format]+json const pattern = /^application\/vnd\.github\.([^.]+)(?:\.([^+]+))?\+json$/; const match = accept.match(pattern); if (!match) return null; return { version: match[1], // e.g., "v3" format: match[2] || 'json', // e.g., "raw", "html", "full" subtype: 'json', };} // GitHub also uses preview features via Accept header// This allows opting into experimental features:// Accept: application/vnd.github.v3.preview+json// Accept: application/vnd.github.symmetra-preview+json (specific preview) function extractPreviews(accept: string): string[] { const previews: string[] = []; const previewPattern = /vnd\.github\.([\w-]+)-preview/g; let match; while ((match = previewPattern.exec(accept)) !== null) { previews.push(match[1]); } return previews;} // Example: Opting into multiple previews// Accept: application/vnd.github.v3+json, // application/vnd.github.mercy-preview+json,// application/vnd.github.baptiste-preview+jsonAccept header versioning is the most REST-compliant but also the most complex for clients. Developers must construct correct media type strings, which is error-prone without SDK support. While theoretically elegant, this complexity has limited its adoption outside GitHub and a few other APIs.
Header versioning requires careful attention to caching. Unlike URL versioning where different versions have different URLs (and thus separate cache entries), header-versioned requests to the same URL may return different content. HTTP provides the Vary header to handle this.
The Vary Header
The Vary header tells caches which request headers affect the response content. When a cache sees Vary: Accept, it knows to cache responses separately for each unique Accept header value.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Proper Caching for Header-Versioned APIs import express, { Request, Response, NextFunction } from 'express'; function cacheControlMiddleware(req: Request, res: Response, next: NextFunction) { // Tell caches to vary by version-related headers const varyHeaders = [ 'Accept', // For Accept header versioning 'API-Version', // For custom header versioning 'Accept-Encoding', // Standard compression negotiation ]; res.setHeader('Vary', varyHeaders.join(', ')); // Set appropriate cache control res.setHeader('Cache-Control', 'public, max-age=3600'); // ETag should include version in its computation const contentHash = computeContentHash(/* content */); const version = req.apiVersion; res.setHeader('ETag', `"${version}-${contentHash}"`); next();} // Cache key examples (how CDNs interpret Vary)://// Without Vary header (WRONG!):// Cache key: "GET /users/123"// Problem: Same key for all versions, serving wrong content//// With Vary: Accept:// Cache key: "GET /users/123 | Accept: application/vnd.example.v1+json"// Cache key: "GET /users/123 | Accept: application/vnd.example.v2+json"// Correct: Separate entries per version // CDN configuration (e.g., Cloudflare, Fastly)const cdnConfig = { // Configure CDN to honor Vary headers cacheKeyModifiers: [ 'req.http.Accept', 'req.http.API-Version', ], // Or normalize to reduce cache fragmentation cacheKeyNormalization: { // Extract just version number, ignore minor format differences transform: (accept: string) => { const version = extractMajorVersion(accept); return `v${version}`; }, },};Essential Response Headers for Versioned APIs
Beyond caching, several response headers provide valuable version information to clients:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Comprehensive Response Headers for Versioned APIs function setVersionHeaders(res: Response, versionInfo: VersionInfo) { // 1. Confirm which version was used res.setHeader('X-API-Version', versionInfo.current); // 2. Inform about latest available version res.setHeader('X-API-Latest-Version', versionInfo.latest); // 3. Deprecation information (RFC 8594) if (versionInfo.isDeprecated) { // Deprecation header with date (when deprecation was announced) res.setHeader('Deprecation', versionInfo.deprecatedSince); // Sunset header (when API will stop working) res.setHeader('Sunset', versionInfo.sunsetDate); // Link to deprecation information res.setHeader('Link', [ '</api/versions>; rel="deprecation"; type="text/html"', `</api/${versionInfo.successorVersion}>; rel="successor-version"`, ].join(', ')); } // 4. Caching headers res.setHeader('Vary', 'Accept, API-Version, Authorization'); // 5. Content-Type matching requested version res.setHeader('Content-Type', versionInfo.contentType); // 6. Rate limit information (often version-specific) res.setHeader('X-RateLimit-Limit', versionInfo.rateLimit.limit); res.setHeader('X-RateLimit-Remaining', versionInfo.rateLimit.remaining); res.setHeader('X-RateLimit-Reset', versionInfo.rateLimit.reset);} // Example response headers://// HTTP/1.1 200 OK// Content-Type: application/vnd.example.v2+json// X-API-Version: 2// X-API-Latest-Version: 3// Vary: Accept, API-Version, Authorization// Cache-Control: public, max-age=3600// ETag: "v2-abc123"// // If deprecated:// Deprecation: Mon, 01 Jan 2024 00:00:00 GMT// Sunset: Mon, 01 Jul 2024 00:00:00 GMT// Link: </api/versions>; rel="deprecation", </api/v3>; rel="successor-version"The Sunset header is standardized in RFC 8594. Use it to communicate when an API version will be retired. Tools and SDKs can parse this header to warn developers about upcoming migration requirements. It's machine-readable deprecation notice.
The most sophisticated APIs combine URL and header versioning to capture the benefits of both. This hybrid approach uses URL versioning for major, breaking changes while header versioning handles minor variations and gradual migrations.
The Two-Layer Version Model
Layer 1: URL Major Version — Stability boundary with guaranteed compatibility Layer 2: Header Minor Version — Fine-grained variations within major versions
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Hybrid Versioning: URL Major + Header Minor // URL structure: /v1/users, /v2/users (major versions)// Header: API-Version: 2023-10-16 (minor variations within major) interface VersionContext { majorVersion: number; // From URL: 1, 2, 3 minorVersion: string; // From header: date or semver effectiveVersion: string; // Combined: "v2.2023-10-16"} function hybridVersionMiddleware( req: VersionedRequest, res: Response, next: NextFunction) { // Extract major version from URL const urlMatch = req.path.match(/^\/v(\d+)/); if (!urlMatch) { return res.status(400).json({ error: 'Major version required in URL' }); } const majorVersion = parseInt(urlMatch[1], 10); // Extract minor version from header (optional) const minorVersion = req.headers['api-version'] as string | undefined; // Resolve effective version req.versionContext = { majorVersion, minorVersion: minorVersion || getLatestMinorForMajor(majorVersion), effectiveVersion: `v${majorVersion}.${minorVersion || 'latest'}`, }; // Validate minor version is compatible with major if (minorVersion && !isCompatible(majorVersion, minorVersion)) { return res.status(400).json({ error: 'Incompatible version specification', majorVersion, requestedMinorVersion: minorVersion, compatibleMinorVersions: getCompatibleMinorVersions(majorVersion), }); } next();} // Example requests://// Latest v1:// GET /v1/users// (no header needed, uses latest v1 behavior)//// Specific v1 minor:// GET /v1/users// API-Version: 2022-06-15//// Latest v2:// GET /v2/users//// Specific v2 minor:// GET /v2/users// API-Version: 2023-10-16 function getLatestMinorForMajor(major: number): string { const minorVersions: Record<number, string> = { 1: '2023-01-15', // Latest v1 minor 2: '2023-10-16', // Latest v2 minor 3: '2023-12-01', // Latest v3 minor }; return minorVersions[major];}Azure's API Versioning Model
Microsoft Azure uses a sophisticated hybrid where the URL contains a query parameter for version, combined with preview feature flags:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// Azure-Style Version Query Parameter // Azure uses api-version as query parameter, not path segment// This keeps paths RESTful while versioning explicitly // Examples:// GET /subscriptions/{id}/providers/Microsoft.Compute/virtualMachines// ?api-version=2023-07-01//// Preview features:// GET /subscriptions/{id}/providers/Microsoft.Compute/virtualMachines// ?api-version=2023-09-01-preview interface AzureVersionContext { apiVersion: string; // e.g., "2023-07-01" isPreview: boolean; // "-preview" suffix present service: string; // Resource provider} function azureVersionMiddleware( req: VersionedRequest, res: Response, next: NextFunction) { const apiVersion = req.query['api-version'] as string; // Azure requires explicit version if (!apiVersion) { return res.status(400).json({ error: { code: 'MissingApiVersionParameter', message: "The 'api-version' query parameter is required.", target: 'api-version', }, }); } // Check for preview suffix const isPreview = apiVersion.endsWith('-preview'); req.versionContext = { apiVersion, isPreview, service: extractServiceProvider(req.path), }; // Preview versions may require explicit opt-in if (isPreview && !hasPreviewAccess(req)) { return res.status(403).json({ error: { code: 'PreviewNotEnabled', message: 'Preview API versions require feature flag enablement.', }, }); } next();} // Benefits of query parameter versioning:// - Keeps path segments for resources only// - Works alongside URL path versioning schemes// - Easy to add/modify without URL restructuring// - Common in enterprise and Azure ecosystemsQuery parameter versioning (?api-version=X) is a middle ground: version is in the URL (visible, cacheable) but not in the path (preserving resource identity). Azure, Google Cloud, and many enterprise APIs use this approach. Consider it when URL path versioning feels too intrusive.
Implementing header versioning requires addressing several practical challenges that don't arise with URL versioning:
Client Development Experience
Header versioning requires clients to correctly set headers, which is more error-prone than constructing URLs. SDKs should abstract this complexity.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// SDK Abstraction for Header Versioning // TypeScript SDK that handles versioning automaticallyclass ExampleAPIClient { private apiVersion: string; private baseUrl: string; constructor(config: ClientConfig) { this.apiVersion = config.apiVersion || '2023-10-16'; this.baseUrl = config.baseUrl || 'https://api.example.com'; } // All requests automatically include version header private async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> { const response = await fetch(`${this.baseUrl}${endpoint}`, { ...options, headers: { 'API-Version': this.apiVersion, 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}`, ...options.headers, }, }); // Check for deprecation warnings const deprecation = response.headers.get('Deprecation'); if (deprecation) { console.warn(`API version ${this.apiVersion} is deprecated.`); console.warn(`Sunset: ${response.headers.get('Sunset')}`); } return response.json(); } // Usage - developers never see versioning complexity async getUser(userId: string): Promise<User> { return this.request(`/users/${userId}`); } async createOrder(data: CreateOrderInput): Promise<Order> { return this.request('/orders', { method: 'POST', body: JSON.stringify(data), }); }} // Usage:const client = new ExampleAPIClient({ apiVersion: '2023-10-16', // Set once, used everywhere apiKey: 'key_xxx',}); const user = await client.getUser('123'); // Version handled internallyTesting Challenges
Browser testing is harder with header versioning. Tools like cURL, Postman, or browser extensions are needed to set custom headers.
123456789101112131415161718192021
# Testing Header-Versioned APIs # Using cURLcurl -H "API-Version: 2023-10-16" \ -H "Authorization: Bearer token123" \ https://api.example.com/users/123 # Using HTTPie (more readable)http GET https://api.example.com/users/123 \ API-Version:2023-10-16 \ Authorization:"Bearer token123" # Browser testing options:# 1. Browser extensions (ModHeader, Requestly)# 2. Built-in DevTools override (limited)# 3. API playground/console on your website# 4. Provide equivalent URL parameter for testing only # Example: Testing-only URL parameter# Allow ?_api_version=X for development/testing# ONLY in non-production environments| Consideration | Challenge | Solution |
|---|---|---|
| Client onboarding | Developers unfamiliar with header versioning | SDKs, clear docs, playground |
| Browser testing | Can't set headers easily | API console, browser extension docs |
| Caching | Same URL, different content | Proper Vary headers, CDN config |
| Logging | Version not in URL logs | Log version header explicitly |
| Default version | What if header missing? | Clear policy, warn in response |
| Error messages | Invalid version diagnosis | Detailed error with valid options |
| Documentation | Version not visible | Version selector in docs UI |
Header versioning requires more documentation effort. Since version isn't visible in URLs, docs must clearly explain how to set version headers, what the default is, and how to discover available versions. Invest in interactive documentation that lets developers experiment with different versions.
Header versioning isn't universally better or worse than URL versioning—each excels in different contexts. Here's guidance on when header versioning is the right choice:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Versioning Strategy Decision Matrix interface VersioningDecision { factors: Factor[]; recommendation: 'url' | 'header' | 'hybrid' | 'query'; rationale: string;} const factorWeights: Record<string, string> = { // Favor URL versioning 'publicApi': 'url', 'browserTesting': 'url', 'simpleInfrastructure': 'url', 'infrequentChanges': 'url', 'diverseConsumers': 'url', // Favor Header versioning 'frequentChanges': 'header', 'dateVersioning': 'header', 'accountPinning': 'header', 'enterpriseFocus': 'header', 'strongSdks': 'header', 'restPurity': 'header', // Favor Hybrid 'bothMajorAndMinor': 'hybrid', 'gradualMigration': 'hybrid', 'complexEvolution': 'hybrid', // Favor Query Parameter 'azureEcosystem': 'query', 'previewFeatures': 'query', 'pathSensitive': 'query',}; function recommendStrategy(factors: string[]): VersioningDecision { // Count recommendations for each strategy const counts = { url: 0, header: 0, hybrid: 0, query: 0 }; factors.forEach(factor => { const rec = factorWeights[factor]; if (rec) counts[rec]++; }); // Find dominant recommendation const max = Math.max(...Object.values(counts)); const recommendation = Object.keys(counts).find(k => counts[k] === max); return { factors, recommendation, rationale: generateRationale(counts), };}For most APIs, start with URL versioning (simpler, more discoverable). Add header-based micro-versioning later if you find yourself needing rapid evolution within major versions. The hybrid approach gives you the best of both worlds without committing to either extreme upfront.
We've explored header-based versioning in depth—from custom headers to Accept header negotiation to sophisticated hybrid strategies. Let's consolidate the key insights:
What's Next:
With URL and header versioning covered, we now turn to the critical topic of breaking vs non-breaking changes. Understanding what constitutes a breaking change—and what doesn't—is essential for minimizing version churn while maintaining backward compatibility.
You now understand header versioning deeply—from custom headers to Accept header negotiation to hybrid strategies. This knowledge equips you to choose the right versioning approach for your API's evolution needs and implement it correctly.