Content negotiation and media types
Accept and Content-Type are how a client and a server agree on representation, and getting the failure codes right makes an API predictable.
The two headers and what they mean
| Header | Direction | Answers the question |
|---|---|---|
Accept | Client to server | Which representations can I read? |
Content-Type | Both | What is the body I am sending? |
Accept-Language | Client to server | Which natural language for messages? |
Accept-Encoding | Client to server | Which compressions can I decode? |
Vary | Server to client | Which request headers changed this response? |
GET /articles/42 HTTP/1.1
Accept: application/json, text/csv;q=0.5, */*;q=0.1HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Vary: Accept
{"id":42,"title":"Caching","status":"published"}qvalues express preference from 0 to 1; a missingqmeans 1.*/*means the client accepts anything, which is why a server that ignores Accept is rarely caught out in practice.- If the client asks for a representation you cannot produce, return 406 Not Acceptable — not 400 and not 200 with a different format.
- If the request body's content type is one you do not support, return 415 Unsupported Media Type.
- The
charsetparameter matters: a JSON body without one is UTF-8 by convention, but a text body is ambiguous.
Designing the negotiation surface
# Vendor or profile media types: give the representation a version-independent name
produces:
- application/json
- application/vnd.example.article+json # a profile with extra fields
consumes:
- application/json
- application/merge-patch+json # partial update, RFC 7396
- multipart/form-data # file and field upload
# Content-Type values your API will actually see in the wild
# application/json normal
# application/json; charset=utf-8 normal, explicit charset
# application/vnd.api+json JSON:API clients
# text/json technically wrong, but some clients send it
# application/json;charset=UTF-8 no space is legal and commonfrom flask import Flask, request, jsonify, Response
app = Flask(__name__)
ARTICLES = {42: {"id": 42, "title": "Caching", "status": "published"}}
@app.get("/articles/<int:aid>")
def get_article(aid):
art = ARTICLES.get(aid)
if art is None:
return jsonify(error="not_found"), 404
best = request.accept_mimetypes.best_match(
["application/json", "text/csv"])
if best is None:
return Response(status=406)
if best == "text/csv":
cols = "id,title,status"
row = ",".join(str(art[k]) for k in ("id", "title", "status"))
return Response(cols + "\n" + row + "\n",
mimetype="text/csv")
return jsonify(art)
@app.post("/articles")
def create_article():
ctype = (request.content_type or "").split(";")[0].strip().lower()
if ctype not in ("application/json", "application/merge-patch+json"):
return jsonify(error="unsupported_media_type"), 415
return jsonify(request.get_json()), 201⚠️
A caching layer keys on the URL unless you tell it otherwise. If the same URL can return JSON or CSV, you must send
Vary: Accept or a shared cache will serve the wrong representation to the next caller.FAQ
Should I version with a media type instead of the URL?
It is more correct and it lets one URL serve several versions, but it breaks in browsers and in tools that cannot set Accept easily. Most public APIs use a URL prefix and reserve media types for representation profiles.
What if a client sends no Content-Type at all?
Do not guess. Return 415 and document the requirement. Guessing is how a JSON parser ends up consuming a form-encoded body and producing a confusing validation error.
Related
Resource modelling and methods Versioning and error shapes
Last refreshed 2026-09-18.