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

HeaderDirectionAnswers the question
AcceptClient to serverWhich representations can I read?
Content-TypeBothWhat is the body I am sending?
Accept-LanguageClient to serverWhich natural language for messages?
Accept-EncodingClient to serverWhich compressions can I decode?
VaryServer to clientWhich request headers changed this response?
GET /articles/42 HTTP/1.1
Accept: application/json, text/csv;q=0.5, */*;q=0.1
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Vary: Accept

{"id":42,"title":"Caching","status":"published"}
  • q values express preference from 0 to 1; a missing q means 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 charset parameter 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 common
from 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.

Resource modelling and methods Versioning and error shapes

Last refreshed 2026-09-18.