08 · Content Negotiation¶
Content negotiation lets a client and server agree on the format of a response (or request) — JSON vs XML vs CSV, English vs Spanish, gzip vs plain — using standard HTTP headers instead of separate URLs per format.
The Accept header¶
The client states, in order of preference, what representations it can handle:
Quality values (q) express weighted preference when multiple types are
acceptable:
Read as: "prefer JSON, XML is an acceptable fallback, and I'll take
anything as a last resort." The server picks the highest-q type it can
actually produce.
406 Not Acceptable¶
If the server truly cannot produce any format the client will accept, it should say so explicitly rather than guessing:
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{
"error": {
"code": "not_acceptable",
"message": "Supported formats: application/json, application/xml"
}
}
In practice, most JSON-only APIs skip strict 406 handling and just always
return JSON regardless of Accept — a pragmatic simplification that's fine
as long as it's documented, but a spec-following API (especially one also
serving XML/CSV consumers) should honor Accept properly.
Content negotiation on the request side: Content-Type¶
While Accept negotiates the response format, Content-Type on the
request tells the server how to parse the request body the client is
sending:
curl -X POST https://api.example.com/books \
-H "Content-Type: application/json" \
-d '{"title": "Dune"}'
curl -X POST https://api.example.com/books \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'title=Dune'
A server receiving an unsupported Content-Type should respond 415
Unsupported Media Type:
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/json
{
"error": { "code": "unsupported_media_type", "message": "Expected application/json" }
}
Language negotiation¶
The same mechanism extends to localization via Accept-Language:
HTTP/1.1 200 OK
Content-Language: es-ES
{"id": 42, "title": "Dune", "description": "Una novela de ciencia ficción..."}
Responses that vary by a negotiated header should include a Vary header
so intermediate caches store separate copies per variant instead of
serving a Spanish response to an English-requesting client:
Versioning via Accept (media-type versioning)¶
An alternative to URL-path versioning (/v1/books, covered in Level 1) is
encoding the version inside a custom media type:
HTTP/1.1 200 OK
Content-Type: application/vnd.example.v2+json
{"id": 42, "title": "Dune", "authors": [{"id": 3, "name": "Frank Herbert"}]}
This is GitHub's approach for some of its API previews
(application/vnd.github.v3+json). It keeps the URL itself
version-agnostic, at the cost of being less discoverable than a version
visible directly in the path — most developers find /v2/books easier to
notice and reason about than a version buried in a header.
Worked example: an API supporting JSON and CSV export¶
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename="sales-report.csv"
date,total
2026-08-01,4200.50
Server-side dispatch:
def get_report(request):
data = build_report()
accept = request.headers.get("Accept", "application/json")
if "text/csv" in accept:
return to_csv(data), "text/csv"
if "application/json" in accept or "*/*" in accept:
return to_json(data), "application/json"
raise NotAcceptable(supported=["application/json", "text/csv"])
How It Actually Works¶
Content negotiation is a real parsing-and-matching algorithm the server
runs against the Accept header, not a simple string equality check.
A client sends: Accept: application/json;q=0.9, application/xml;q=0.5,
*/*;q=0.1 — meaning "prefer JSON, XML is acceptable, otherwise anything."
The server's negotiation logic:
1. Parse each media type + its q-value (default q=1.0 if omitted).
2. Sort by q-value descending: [json:0.9, xml:0.5, */*:0.1]
3. Walk this list; for each, check if the server can actually produce it.
4. Return the first mutual match, with matching Content-Type set.
5. If nothing matches: 406 Not Acceptable.
This is genuinely a negotiation, not a client dictate — the server's
list of what it can produce (its registered serializers) intersects
with the client's ordered preference list, and the first mutual match
wins. A server that only implements a JSON serializer will always return
JSON regardless of the Accept header's XML preference, correctly falling
through to whatever the server can do (many APIs skip strict 406
enforcement and just default to JSON for pragmatism).
Content-Type on the response is not negotiated — it's the server
stating, after the fact, which format it actually chose, so the client's
parser knows how to deserialize the body it's about to read. Confusing
Accept (what I can read) with Content-Type (what I'm sending/what this
is) is the single most common content-negotiation bug in hand-rolled
API clients.
Exercise¶
- A client sends
Accept: application/xml;q=1.0, application/json;q=0.5to a server that only supports JSON. What should the response be:200with JSON, or406? Justify both a strict and a pragmatic answer. - Why does a response that varies by
Accept-Languageneed aVaryheader for correctness under a shared CDN cache? Describe the bug that happens without it. - Compare header-based (
Accept: application/vnd.example.v2+json) vs URL-based (/v2/books) versioning on: discoverability, cache-key simplicity, and ease of routing in a reverse proxy. - Design the
Content-Typehandling for an endpoint that must accept both JSON bodies andmultipart/form-data(e.g. for a book cover image upload alongside metadata).