Header-Based API Versioning — Complete Guide
In this tutorial, you'll learn about Header-Based Versioning. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Header-based versioning specifies the API version through HTTP headers rather than the URL path, keeping URLs clean while enabling content negotiation.
What You'll Learn
By the end of this lesson, you will implement Accept header versioning and custom header versioning, and understand the trade-offs compared to URI versioning.
Why It Matters
Header versioning keeps URLs clean and RESTful, which is important for APIs where URL structure must remain stable for Caching or SEO reasons.
Real-World Use
GitHub API uses the Accept header for custom media types: application/vnd.github.v3+json. This keeps URLs clean while supporting version-specific responses.
Header Versioning Flow
sequenceDiagram
Client->>API: GET /users
Client->>API: Accept: application/vnd.myapp.v2+json
API->>API: Parse Accept header
API->>Client: V2 Response
Accept Header Versioning
# accept_header.py
import re
from typing import Dict, Optional, Tuple
class AcceptHeaderVersioning:
def __init__(self):
self.pattern = re.compile(r'application/vnd\.(\w+)\.v(\d+)\+(\w+)')
def parse(self, headers: Dict[str, str]) -> Tuple[Optional[int], str]:
accept = headers.get('Accept', '')
match = self.pattern.search(accept)
if match:
api_name = match.group(1)
version = int(match.group(2))
format_type = match.group(3)
return version, f"application/vnd.{api_name}.v{version}+{format_type}"
return None, "default"
def route(self, headers: Dict, v1_handler, v2_handler) -> Dict:
version, _ = self.parse(headers)
if version == 2:
return v2_handler()
elif version == 1:
return v1_handler()
else:
return v1_handler()
versioning = AcceptHeaderVersioning()
def v1_response():
return {"users": [{"id": 1, "name": "Alice"}], "version": "v1"}
def v2_response():
return {"data": [{"id": 1, "name": "Alice", "profile": {"bio": "Dev"}}],
"meta": {"version": "v2"}}
headers_list = [
{"Accept": "application/vnd.myapp.v2+json"},
{"Accept": "application/vnd.myapp.v1+json"},
{"Accept": "application/json"},
]
for headers in headers_list:
version, source = versioning.parse(headers)
result = versioning.route(headers, v1_response, v2_response)
print(f"Accept: {headers['Accept']:40s} -> version={version} -> {result['meta']['version'] if 'meta' in result else result['version']}")
Expected output:
Accept: application/vnd.myapp.v2+json -> version=2 -> v2
Accept: application/vnd.myapp.v1+json -> version=1 -> v1
Accept: application/json -> version=None -> v1
Custom Header Versioning
# custom_header.py
from typing import Dict, Optional, Tuple
class CustomHeaderVersioning:
def __init__(self, header_name: str = "X-API-Version"):
self.header_name = header_name
def detect(self, headers: Dict) -> Optional[int]:
version_str = headers.get(self.header_name)
if version_str:
try:
return int(version_str)
except (ValueError, TypeError):
return None
return None
def route(self, headers: Dict, handlers: Dict[int, callable],
default_version: int = 1) -> Dict:
version = self.detect(headers) or default_version
handler = handlers.get(version)
if not handler:
return {"error": f"Version {version} not supported",
"supported": list(handlers.keys())}
return handler()
versioning = CustomHeaderVersioning("X-API-Version")
handlers = {
1: lambda: {"users": [], "version": "v1"},
2: lambda: {"data": [], "meta": {"version": "v2"}},
3: lambda: {"data": [], "meta": {"version": "v3"}},
}
tests = [
{"X-API-Version": "2"},
{"X-API-Version": "1"},
{"X-API-Version": "4"},
{},
]
for headers in tests:
result = versioning.route(headers, handlers)
print(f"Headers: {headers:30s} -> {result.get('meta', result).get('version', result.get('error', 'unknown'))}")
Expected output:
Headers: {'X-API-Version': '2'} -> v2
Headers: {'X-API-Version': '1'} -> v1
Headers: {'X-API-Version': '4'} -> Version 4 not supported
Headers: {} -> v1
Common Mistakes
1. Not Documenting Header Format
Clients cannot guess the header format. Document exactly which headers and values to use.
2. Ignoring Content Negotiation Standards
The Accept header has defined semantics. Use Accept: application/vnd.api.v2+json rather than custom headers when possible.
3. No Fallback for Missing Headers
Always have a default version when no version header is provided. Usually the latest stable version.
4. Not Supporting Both GET and POST
Headers work for all HTTP methods, but make sure your middleware handles all methods consistently.
5. Browser CORS Issues
Custom headers require CORS preflight requests. Ensure your server handles OPTIONS requests and includes the custom header in Access-Control-Allow-Headers.
Practice Questions
1. What is the advantage of header versioning over URI versioning?
URLs stay clean and unchanging. The same URL can serve different versions based on the client's capabilities.
2. What header is commonly used for versioning?
The Accept header with custom media types like application/vnd.myapp.v2+json.
3. How does header versioning affect caching?
All versions share the same URL, making CDN caching harder. Cache keys must include the version header.
4. What happens if a client sends no version header?
Default to the latest stable version and return a warning header indicating the assumed version.
Challenge
Implement header-based versioning for a product API that supports versions 1-3 via both Accept header and X-API-Version custom header, with proper version resolution and fallback.
FAQ
Mini Project: Dual Version Detector
# version_detector.py
import re
from typing import Dict, Optional
class DualVersionDetector:
def __init__(self):
self.accept_pattern = re.compile(r'vnd\.\w+\.v(\d+)')
def detect(self, headers: Dict) -> Optional[int]:
accept = headers.get("Accept", "")
match = self.accept_pattern.search(accept)
if match:
return int(match.group(1))
x_ver = headers.get("X-API-Version")
if x_ver:
return int(x_ver)
return None
detector = DualVersionDetector()
tests = [
{"Accept": "application/vnd.myapp.v2+json"},
{"X-API-Version": "3", "Accept": "application/json"},
{"Accept": "application/json"},
]
for h in tests:
v = detector.detect(h)
print(f"Version: {v or 'default (1)'}")
Expected output:
Version: 2
Version: 3
Version: default (1)
What's Next
You understand header versioning. Next, learn about query parameter versioning, then explore content negotiation.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro