REST API Content Negotiation: A Practical Guide

Design, implement, and test REST API content negotiation: headers, q-values, media types, versioning, caching, and error handling with practical examples.

ASOasis
5 min read
REST API Content Negotiation: A Practical Guide

Image used for representation purposes only.

What is content negotiation?

Content negotiation is the HTTP-driven process that lets a client and server agree on the most appropriate representation of a resource. Rather than exposing separate endpoints for JSON, XML, or different languages, a single URL can serve multiple formats based on headers and quality preferences. Done well, negotiation improves developer experience, cacheability, extensibility, and backward compatibility.

The key headers

  • Accept: Lists the media types the client can handle for the response, optionally with quality factors (q).
  • Content-Type: Declares the media type of the request body the client is sending.
  • Accept-Language: Preferred natural languages (e.g., en-US, fr-FR).
  • Accept-Encoding: Acceptable content-codings for transfer (e.g., gzip, br). Typically orthogonal to representation but affects payload delivery.
  • Vary: Response header that tells caches which request headers affect the selected representation (e.g., Vary: Accept, Accept-Language).

Notes:

  • Accept-Charset is rarely used today; UTF-8 is the de facto standard for JSON.
  • Do not conflate Content-Type (what you send) with Accept (what you want back).

Media types in practice

  • Core types: application/json, application/xml, text/csv
  • Suffixes: application/vnd.acme.invoice+json (the +json suffix signals JSON tooling compatibility)
  • Parameters: charset=utf-8, profile, version (e.g., application/json;profile=“https://example.com/schemas/invoice";version=2 )

Prefer standardized media types when possible. If you use vendor types, also publish the semantics and schemas.

How servers pick a representation

Servers typically follow two rules:

  1. Specificity wins
  • application/json beats application/*, which beats /
  1. Highest quality factor (q) wins within the same specificity
  • Example: Accept: application/xml;q=0.9, application/json;q=0.8 → choose XML

If no acceptable representation exists, respond 406 Not Acceptable. If the request body’s Content-Type is unsupported, respond 415 Unsupported Media Type.

Example selection

Request:

GET /invoices/42 HTTP/1.1
Accept: application/json;q=0.6, application/*;q=0.4, */*;q=0.1
Accept-Language: en-GB,en;q=0.8

Server supports: [application/json, application/xml]. It chooses application/json (q=0.6) and may localize messages for en-GB.

Response:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: en-GB
Vary: Accept, Accept-Language

{"id":42,"total":123.45,"currency":"GBP"}

Designing your negotiation strategy

Choose one of the following, then document it clearly:

  • Strict negotiation

    • If Accept missing or unacceptable → 406.
    • Pros: Predictable; easy to reason about.
    • Cons: More client friction, especially with generic tools.
  • Pragmatic default with validation

    • Default to application/json when Accept is missing or /.
    • Honor q-values and specific client asks when provided.
    • Return 406 only when the client explicitly excludes all supported types.
    • Pros: Developer-friendly; aligns with most public APIs.

Recommendation for most REST APIs: pragmatic default to application/json while supporting explicit negotiation.

Request bodies: consumes vs produces

  • Client → Server: Content-Type of the request body must match what the server accepts. If unsupported, return 415 and list supported types.
  • Server → Client: Negotiated via Accept for the response. These are independent decisions.

Example 415 error response using Problem Details:

HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json

{
  "type": "https://example.com/problems/unsupported-media-type",
  "title": "Unsupported media type",
  "status": 415,
  "detail": "Only application/json is supported for this endpoint.",
  "supported": ["application/json"]
}

Versioning via content negotiation

Content negotiation can carry version signals without changing URLs:

  • Vendor media types: application/vnd.acme.invoice.v2+json
  • Media type parameters: application/json;version=2
  • Profile parameter (RFC 6906): application/json;profile=“https://example.com/profiles/invoice-v2"

Pros:

  • Single canonical URL; cache-friendly; encourages backward compatibility.

Cons:

  • Tooling and developer familiarity may lag compared to URL or header-based versioning.

Tip: If you negotiate versions, keep a stable default (e.g., latest compatible) and document an explicit way to request older versions.

Caching and CDNs

  • Always set Vary to reflect the headers that influence representation:
    • Vary: Accept for format
    • Vary: Accept-Language for localization
    • Vary: Origin/Authorization rarely, and only if they truly vary the representation (mind cache fragmentation).
  • Use strong validators (ETag) per representation. Different formats or languages must have distinct ETags.
  • Prefer stable defaults (e.g., JSON, en-US) to maximize cache hit ratios.

Security considerations

  • Send the correct Content-Type and include charset where relevant (application/json; charset=utf-8).
  • For browser-facing APIs, mitigate content sniffing: X-Content-Type-Options: nosniff.
  • Validate and bound Accept header parsing to avoid DoS from pathological header lengths.
  • Avoid reflecting untrusted Accept values in error messages without sanitization.

Implementation patterns

Minimal selection algorithm (pseudocode)

function negotiate(supported, acceptHeader):
  if acceptHeader is empty:
    return defaultRepresentation(supported)

  parsed = parseAccept(acceptHeader) // -> list of {type, subtype, q}
  // Expand wildcards into matches with decreasing specificity
  candidates = []
  for s in supported:
    for a in parsed:
      if matches(a, s):
        candidates.add({media: s, q: a.q, specificity: specificityScore(a)})

  if candidates is empty:
    throw 406

  sort by (q desc, specificity desc, serverPreference desc)
  return candidates[0].media

Node.js (Express)

app.get('/invoices/:id', (req, res) => {
  const supported = ['application/json', 'application/xml'];
  const type = req.accepts(supported) || null; // Express handles q-values & wildcards
  if (!type) return res.status(406).send('Not Acceptable');

  const invoice = { id: req.params.id, total: 123.45 };
  res.set('Vary', 'Accept');
  if (type === 'application/xml') {
    res.type('application/xml').send(`<invoice><id>${invoice.id}</id></invoice>`);
  } else {
    res.type('application/json').json(invoice);
  }
});

Spring Boot (produces/consumes)

@RestController
@RequestMapping(value = "/invoices", produces = {"application/json", "application/xml"})
public class InvoiceController {
  @GetMapping("/{id}")
  public Invoice get(@PathVariable String id) { return service.get(id); }

  @PostMapping(consumes = "application/json")
  public Invoice create(@RequestBody Invoice in) { return service.save(in); }
}

Go (net/http)

func negotiate(w http.ResponseWriter, r *http.Request) string {
  accept := r.Header.Get("Accept")
  if accept == "" || accept == "*/*" { return "application/json" }
  // naive example; use a real parser for q-values
  if strings.Contains(accept, "application/xml") { return "application/xml" }
  if strings.Contains(accept, "application/json") { return "application/json" }
  w.WriteHeader(http.StatusNotAcceptable)
  return ""
}

Testing your negotiation

  • Use curl to simulate preferences:
curl -H "Accept: application/xml, application/json;q=0.8" \
     -H "Accept-Language: fr-FR, en;q=0.7" \
     https://api.example.com/invoices/42 -i
  • Negative tests:

    • Accept excludes all server types → expect 406
    • Unsupported request Content-Type → expect 415 with helpful body
  • Contract tests: Include negotiation cases in CI with language/format matrices.

Documentation tips

  • Enumerate supported media types, languages, and encodings per endpoint.
  • Describe defaults and how q-values are honored.
  • Show examples for 200, 406, and 415 with Content-Type and Vary headers.
  • If using vendor media types or profile parameters, publish schemas and change logs.

Common pitfalls (and fixes)

  • Forgetting Vary: Accept → stale or incorrect cached representations. Always set it when format can change.
  • Ignoring q-values → surprising client behavior. Respect them or document that you don’t.
  • Tying versioning to URLs only → painful migrations. Consider conneg or header-based versions where appropriate.
  • Emitting text/html error pages from APIs → inconsistent DX. Use application/problem+json or your standard error type.

Summary

Effective content negotiation aligns client intent with server capability while preserving a single canonical URL. Implement a pragmatic default (usually JSON), respect q-values and specificity, return 406/415 when necessary, version with media types when it fits your ecosystem, and make caches happy with correct Vary and ETag discipline. With the patterns above, your REST API will be flexible, predictable, and future-proof.

Related Posts