API · Rest

REST API Content Negotiation

How Accept and Content-Type headers drive real content negotiation in a REST API, and where most implementations only pretend to support it.

John Kihiu12 min read

Content negotiation is HTTP's mechanism for letting a client and server agree on a representation — format, language, encoding — without the client having to know in advance exactly what the server can produce. Most REST APIs claim to support it and then ship a single hardcoded JSON response regardless of what the Accept header says, which isn't wrong exactly (JSON-only is a legitimate design choice) but is a different thing from actually negotiating. Worth knowing the difference, because the moment you need to support a second format — a CSV export, a PDF invoice, an API version bump — real content negotiation is the mechanism that was designed for exactly that.

Accept drives server-driven negotiation

The client sends Accept: application/json, application/xml;q=0.8 — a ranked list of acceptable media types with quality values — and the server picks the best match it can produce, responding with Content-Type set to whatever it chose. This is server-driven negotiation: the server has final say, using the client's stated preferences as input. If the server can't produce anything the client will accept, the correct response is 406 Not Acceptable, a status code that exists for exactly this and is underused because most APIs don't bother checking Accept at all.

HTTP · CONTENT NEGOTIATION
GET /v1/invoices/4471 HTTP/1.1
Accept: application/vnd.example.v2+json, application/json;q=0.5

HTTP/1.1 200 OK
Content-Type: application/vnd.example.v2+json
Vary: Accept

{"id": 4471, "status": "open", "total_cents": 152000}

Media-type versioning is content negotiation in disguise

Custom vendor media types like application/vnd.example.v2+json use the exact same negotiation mechanism to solve API versioning: the client states which version's representation it wants via Accept, and the server responds with that shape or a compatible fallback. This is a genuinely elegant use of a header that already exists for this purpose, though in practice it loses to URI versioning (/v2/invoices) most of the time because URI versioning is visible in logs, cacheable without a Vary: Accept header, and pastable into a browser address bar for quick debugging — none of which media-type versioning offers for free.

Content-Type on the request is not optional

Content negotiation isn't only about responses. A POST or PUT request body needs an accurate Content-Type so the server knows how to parse it — application/json versus application/x-www-form-urlencoded versus multipart/form-data are parsed completely differently, and a server that guesses based on content sniffing instead of trusting (and validating) the header is inviting the same class of confusion-attack that made MIME sniffing a browser security problem for years. Reject requests with a missing or unsupported Content-Type with 415 Unsupported Media Type rather than guessing.

Vary: Accept, every time you negotiate on it

If a response's Content-Type depends on the request's Accept header, any cache sitting between client and server needs Vary: Accept to key its stored responses correctly — otherwise the first client to hit a URL determines what every subsequent client gets served from cache, regardless of what they asked for.

When JSON-only is the right call

Most internal and B2B APIs never need more than one representation format, and building out full Accept-header negotiation machinery for a service that will only ever speak JSON is effort spent on flexibility nobody asked for. The honest version of "JSON-only" is still emitting Content-Type: application/json correctly and returning 406 if a client explicitly asks for something else via Accept — that's a few lines of middleware, not a negotiation framework, and it keeps the contract truthful instead of silently ignoring what the client asked for.

Don't negotiate on file extension

/invoices/4471.xml as an alternative to header-based negotiation looks convenient but conflates the resource's identity with its representation — the same invoice now has two URLs, which breaks caching, linking, and any client that assumes one resource equals one canonical URL. If you need this ergonomics win, add a query parameter and document it as a convenience alias, not a routing rule.

Wrapping up

Real content negotiation means checking Accept on the way out and Content-Type on the way in, returning 406 and 415 when neither side can agree, and declaring Vary so caches don't serve the wrong representation to the wrong client. If your API only ever speaks one format, say so honestly through the same status codes rather than building negotiation machinery you don't need.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.