Skip to content

Header-Based API Versioning — Complete Guide

DodaTech Updated 2026-06-28 4 min read

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

Is header versioning more RESTful than URI versioning?

Many purists argue yes, because the URL identifies the resource, not the version. The version is part of content negotiation.

How do I test header versioning with curl?

Use -H 'Accept: application/vnd.myapp.v2+json' or -H 'X-API-Version: 2'.

Can I support both URI and header versioning?

Yes, but keep it simple. Supporting both increases complexity. Choose one primary strategy.

Does header versioning work with WebSockets?

WebSocket connections use the Upgrade header during handshake. Include version in the handshake request.

How do browser dev tools show versioned requests?

Headers are visible in the Network tab. URI versioning is visible in the URL column directly.

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