Skip to content

Content Negotiation for API Versioning

DodaTech Updated 2026-06-28 5 min read

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

Content negotiation allows clients and servers to agree on the response format, including the API version, through HTTP headers like Accept and Content-Type.

What You'll Learn

By the end of this lesson, you will implement server-driven content negotiation for versioning using Accept headers and understand how content negotiation works in HTTP.

Why It Matters

Content negotiation is a standard HTTP mechanism. Using it for versioning aligns with HTTP semantics and leverages existing infrastructure like cache Vary headers.

Real-World Use

APIs that serve both JSON and XML can use content negotiation to also negotiate the version. The client requests application/vnd.api.v2+json and gets the v2 JSON response.

Content Negotiation Flow

sequenceDiagram
    Client->>Server: GET /resource
    Client->>Server: Accept: application/vnd.api.v2+json
    Server->>Server: Match version from Accept
    Server->>Client: 200 OK, Content-Type: application/vnd.api.v2+json

Server-Driven Negotiation

# content_neg.py
import re
from typing import Dict, List, Optional, Tuple

class ContentNegotiator:
    def __init__(self):
        self.media_types: Dict[str, callable] = {}

    def register(self, media_type: str, handler: callable):
        self.media_types[media_type] = handler

    def negotiate(self, accept_header: str) -> Tuple[Optional[str], Optional[Dict]]:
        types = [t.strip() for t in accept_header.split(",")]

        for requested in types:
            requested = requested.split(";")[0].strip()

            if requested in self.media_types:
                handler = self.media_types[requested]
                return requested, handler()

            for registered_type in self.media_types:
                if self._wildcard_match(requested, registered_type):
                    return registered_type, self.media_types[registered_type]()

        return None, None

    def _wildcard_match(self, requested: str, registered: str) -> bool:
        req_parts = requested.split("/")
        reg_parts = registered.split("/")
        if req_parts[0] == "*/*" or reg_parts[0] == "*/*":
            return True
        if req_parts[0] == reg_parts[0] or req_parts[0] == "*":
            return req_parts[1] == "*" or reg_parts[1] == "*" or req_parts[1] == reg_parts[1]
        return False

negotiator = ContentNegotiator()

def v1_json():
    return {"version": "v1", "format": "json", "data": [1, 2, 3]}

def v2_json():
    return {"version": "v2", "format": "json", "data": [{"id": 1}, {"id": 2}], "meta": {"count": 2}}

def v1_xml():
    return "<response><version>v1</version></response>"

negotiator.register("application/vnd.api.v1+json", v1_json)
negotiator.register("application/vnd.api.v2+json", v2_json)
negotiator.register("application/vnd.api.v1+xml", v1_xml)

tests = [
    "application/vnd.api.v2+json",
    "application/vnd.api.v1+json",
    "application/json",
    "text/html",
]

for accept in tests:
    media_type, result = negotiator.negotiate(accept)
    if result:
        print(f"Accept: {accept:40s} -> {media_type}: version={result.get('version', 'N/A')}")
    else:
        print(f"Accept: {accept:40s} -> Not acceptable (406)")

Expected output:

Accept: application/vnd.api.v2+json           -> application/vnd.api.v2+json: version=v2
Accept: application/vnd.api.v1+json           -> application/vnd.api.v1+json: version=v1
Accept: application/json                       -> Not acceptable (406)
Accept: text/html                              -> Not acceptable (406)

Quality Values (q权重)

Clients can Express preference using quality values:

Accept: application/vnd.api.v2+json;q=0.9, application/vnd.api.v1+json;q=0.5

This means the client prefers v2 but will accept v1.

# quality_values.py
from typing import Dict, List, Optional, Tuple

class QualityNegotiator:
    def parse(self, accept: str) -> List[Tuple[str, float]]:
        types = []
        for item in accept.split(","):
            parts = item.strip().split(";")
            media_type = parts[0].strip()
            q = 1.0
            for part in parts[1:]:
                if part.strip().startswith("q="):
                    try:
                        q = float(part.strip()[2:])
                    except ValueError:
                        pass
            types.append((media_type, q))
        return sorted(types, key=lambda x: -x[1])

negotiator = QualityNegotiator()
accept = "application/vnd.api.v2+json;q=0.9, application/vnd.api.v1+json;q=0.5"
for media_type, q in negotiator.parse(accept):
    print(f"  {media_type:40s} q={q}")

Expected output:

  application/vnd.api.v2+json                q=0.9
  application/vnd.api.v1+json                q=0.5

Common Mistakes

1. Not Returning Vary Header

Without Vary: Accept, caches may serve the wrong version. Always include Vary header with the Accept header.

2. Ignoring Quality Values

Clients express preference through q-values. If you cannot serve the preferred type, try the next best match instead of returning 406.

3. Not Handling 406 Properly

When no acceptable representation is available, return 406 Not Acceptable with a list of supported media types.

4. Mixing Version and Format Negotiation

Version and format (JSON vs XML) should be negotiated separately. The Accept header can handle both.

5. No Default Representation

Always define a default representation for clients that do not send Accept headers or send generic values.

Practice Questions

1. What is content negotiation in HTTP?

The Process where client and server agree on the response format through request headers like Accept.

2. How does the Vary header affect content negotiation?

Vary: Accept tells caches that the response varies based on the Accept header, preventing cache poisoning.

3. What does the q parameter do in Accept headers?

It indicates the client's preference weight (0-1). Higher q values indicate stronger preference.

4. What status code indicates content negotiation failure?

406 Not Acceptable, meaning the server cannot produce a response matching the client's Accept header.

Challenge

Implement a content negotiation system for a multi-format, multi-version API that serves JSON v1, JSON v2, XML v1, and Protobuf v2 based on Accept headers.

FAQ

Does content negotiation work with all HTTP methods?

Yes. Content negotiation applies to any HTTP method where the client specifies acceptable response formats.

How do browsers handle content negotiation?

Browsers send Accept headers automatically. For custom media types, use fetch() with explicit Accept headers.

Can I use Content-Type for versioning?

Content-Type describes the request body format. Use Accept for response format and version negotiation.

What is proactive vs reactive negotiation?

Proactive (server-driven): server chooses representation. Reactive (agent-driven): server lists options, client chooses.

How does content negotiation affect RESTful design?

Content negotiation aligns with REST principles by keeping resource URLs stable while varying representations.

Mini Project: Full Content Negotiator

# full_negotiator.py
from typing import Any, Dict, Optional, Tuple

class FullContentNegotiator:
    def __init__(self):
        self.representations: Dict[str, callable] = {}

    def add(self, media_type: str, handler: callable):
        self.representations[media_type] = handler

    def handle(self, accept: str) -> Tuple[int, Any, str]:
        if not accept or accept == "*/*":
            media_type = list(self.representations.keys())[0]
            return 200, self.representations[media_type](), media_type

        for requested in accept.split(","):
            mt = requested.split(";")[0].strip()
            if mt in self.representations:
                return 200, self.representations[mt](), mt

        supported = list(self.representations.keys())
        return 406, {"error": "Not acceptable", "supported": supported}, ""

neg = FullContentNegotiator()
neg.add("application/vnd.api.v1+json", lambda: {"v": "1"})
neg.add("application/vnd.api.v2+json", lambda: {"v": "2", "extra": True})

status, body, mt = neg.handle("application/vnd.api.v2+json")
print(f"200: v{body['v']}")

status, body, mt = neg.handle("text/html")
print(f"{status}: {body}")

Expected output:

200: v2
406: {'error': 'Not acceptable', 'supported': ['application/vnd.api.v1+json', 'application/vnd.api.v2+json']}

What's Next

You understand content negotiation. Next, learn about media type versioning, then explore semantic versioning for APIs.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro