Skip to content

Accept Header Versioning — Custom Media Types for API Versioning

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you'll learn about Accept Header. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Accept header versioning embeds API version information in the HTTP Accept header using custom media types, keeping URLs clean while supporting content negotiation.

What You'll Learn

By the end of this lesson, you will implement custom media types for API versioning, parse Accept headers for version extraction, handle version-based Serialization, and return proper 406 responses.

Why It Matters

Accept header versioning follows REST principles by keeping resource URIs stable while varying representation format through content negotiation.

Real-World Use

Durga Antivirus Pro uses Accept: application/vnd.durga.scan.v2+json for its v2 scan API, keeping the /api/scan endpoint URI unchanged across versions.

Custom Media Type Format

import re
from typing import Dict, Optional, Tuple

class AcceptHeaderParser:
    def __init__(self, vendor_prefix: str = "vnd.durga"):
        self.vendor_prefix = vendor_prefix

    def parse_accept(self, accept: str) -> Optional[Dict]:
        media_types = [
            m.strip().split(";")[0].strip()
            for m in accept.split(",")
        ]
        for media_type in media_types:
            result = self._parse_media_type(media_type)
            if result:
                return result
        return None

    def _parse_media_type(self, media_type: str
                          ) -> Optional[Dict]:
        pattern = (
            rf"application/{self.vendor_prefix}\."
            rf"(\w+)\.v(\d+)\+(\w+)"
        )
        match = re.match(pattern, media_type)
        if match:
            return {
                "resource": match.group(1),
                "version": int(match.group(2)),
                "format": match.group(3),
            }
        return None

    def build_accept_header(self, resource: str,
                            version: int,
                            fmt: str = "json") -> str:
        return (f"application/{self.vendor_prefix}."
                f"{resource}.v{version}+{fmt}")

parser = AcceptHeaderParser()
result = parser.parse_accept(
    "application/vnd.durga.scan.v2+json"
)
print(f"Parsed: {result}")
header = parser.build_accept_header("scan", 2)
print(f"Header: {header}")

Content Negotiation Router

Route requests based on Accept header version.

from typing import Dict, Optional, Callable

class ContentNegotiationRouter:
    def __init__(self, vendor_prefix: str = "vnd.api"):
        self.vendor_prefix = vendor_prefix
        self.handlers: Dict[str, Dict[int, Callable]] = {}

    def register(self, resource: str,
                 version: int,
                 handler: Callable):
        if resource not in self.handlers:
            self.handlers[resource] = {}
        self.handlers[resource][version] = handler

    def route(self, path: str, accept: str,
              request: Dict) -> Dict:
        parsed = self._parse_accept(accept)
        if not parsed:
            return {"status": 406,
                    "body": {"error": "Media type not acceptable"}}
        resource = parsed["resource"]
        version = parsed["version"]
        resource_handlers = self.handlers.get(resource)
        if not resource_handlers:
            return {"status": 406,
                    "body": {"error": f"Unknown resource: {resource}"}}
        handler = resource_handlers.get(version)
        if not handler:
            return {"status": 406,
                    "body": {"error": f"Version {version} not supported"}}
        return handler(request)

    def _parse_accept(self, accept: str) -> Optional[Dict]:
        import re
        pattern = (
            rf"application/{self.vendor_prefix}\."
            rf"(\w+)\.v(\d+)\+(\w+)"
        )
        for mt in accept.split(","):
            match = re.match(pattern, mt.strip())
            if match:
                return {
                    "resource": match.group(1),
                    "version": int(match.group(2)),
                    "format": match.group(3),
                }
        return None

cnr = ContentNegotiationRouter()
cnr.register("scan", 1, lambda r: {
    "status": 200, "body": {"version": "v1"}})
cnr.register("scan", 2, lambda r: {
    "status": 200, "body": {"version": "v2"}})
result = cnr.route("/api/scan",
    "application/vnd.api.scan.v2+json", {})
print(f"Content negotiated: {result['body']['version']}")

Response Serialization by Version

Serialize responses differently based on version.

from typing import Dict, Any, Optional, Callable
import json

class VersionedSerializer:
    def __init__(self):
        self.serializers: Dict[str, Dict[int, Callable]] = {}

    def register(self, resource: str,
                 version: int,
                 serializer: Callable):
        if resource not in self.serializers:
            self.serializers[resource] = {}
        self.serializers[resource][version] = serializer

    def serialize(self, resource: str,
                  version: int,
                  data: Dict) -> str:
        resource_serializers = self.serializers.get(resource)
        if not resource_serializers:
            return json.dumps(data)
        serializer = resource_serializers.get(version)
        if not serializer:
            serializer = resource_serializers.get(
                max(resource_serializers.keys())
            )
        if serializer:
            return serializer(data)
        return json.dumps(data)

    def v1_scan_serializer(self, data: Dict) -> str:
        return json.dumps({
            "scan_id": data.get("id"),
            "status": data.get("state"),
        })

    def v2_scan_serializer(self, data: Dict) -> str:
        return json.dumps({
            "id": data.get("id"),
            "state": data.get("state"),
            "result": data.get("result"),
        })

ser = VersionedSerializer()
ser.register("scan", 1, ser.v1_scan_serializer)
ser.register("scan", 2, ser.v2_scan_serializer)
data = {"id": "abc", "state": "completed", "result": "clean"}
v1_result = ser.serialize("scan", 1, data)
v2_result = ser.serialize("scan", 2, data)
print(f"V1: {v1_result}")
print(f"V2: {v2_result}")

Common Mistakes

Mistake 1: Non-Standard Media Types

Custom media types should follow the vendor tree convention: application/vnd.company.resource.vN+format.

Mistake 2: Ignoring Quality Values

Accept headers can include quality values (q=0.9). Parse and respect them for proper negotiation.

Mistake 3: No 406 Response

When no acceptable version is found, return 406 Not Acceptable with the list of supported media types.

Mistake 4: Case Sensitivity

HTTP headers are case-insensitive but media type parameters may not be. Normalize to lowercase.

Mistake 5: Breaking Cacheability

Content negotiation requires Vary: Accept for proper Caching. Without it, caches serve wrong versions.

Practice Questions

  1. What is the format of a custom media type for versioning?
  2. How does the Vary: Accept header affect caching?
  3. What HTTP status code indicates an unacceptable version?
  4. How do quality values in Accept headers work?
  5. Why do custom media types follow the vendor tree convention?

Challenge

Build an Accept header versioning system that parses custom media types (application/vnd.api.resource.vN+json), routes to the correct version handler, serializes responses appropriately per version, returns 406 for unsupported versions, and sets Vary: Accept for proper caching.

FAQ

What is the format of a custom media type?

The format is application/vnd.{vendor}.{resource}.v{version}+{format}, for example application/vnd.api.scan.v2+json.

How does Accept header versioning differ from URI versioning?

Accept header versioning keeps URIs stable and uses content negotiation. URI versioning puts the version in the URL path.

Is Accept header versioning RESTful?

Yes. It follows REST principles by using content negotiation to vary the representation format based on client capabilities.

How do caches handle versioned Accept headers?

Caches require the Vary: Accept header to store separate copies for different Accept values. Without it, cached responses may serve the wrong version.

What happens if a client sends an unsupported media type?

Return 406 Not Acceptable with a list of supported media types in the response body.

Mini Project

Build a complete Accept header versioning system with custom media type Parsing, versioned response serializers, a Vary: Accept header policy, proper 406 error handling for unsupported versions, and support for multiple API resources.

What's Next

Learn about Custom Header Versioning for a simpler header-based approach, or explore Media Type Versioning for deeper content negotiation patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro