Skip to content
Control Plane Labs

406 Not Acceptable

Client error response defined by RFC 9110 §15.5.7.

Last updated July 27, 2026

Common causes at a glance

  • A framework enforcing Accept strictly instead of falling back to a default
  • A client sending Accept: application/json to an endpoint that only emits XML
  • Accept-Language with no wildcard fallback against a single-language site
  • A proxy rewriting or dropping Accept so the server sees something unexpected

Reproduce it

curl -i -H 'Accept: application/xml' https://api.example.com/v1/items

What 406 Not Acceptable means

Content negotiation is the mechanism where a client states preferences with Accept, Accept-Language, and friends, and the server picks a representation. 406 is what happens when nothing on offer satisfies those constraints and the server declines to guess.

The important word is “declines”. A server is always permitted to ignore the preferences and send something reasonable, and most should. Returning 406 turns a cosmetic mismatch into a hard failure, and since the client cannot usually renegotiate automatically, the request is dead. That is why 406 in the wild points at strict server configuration rather than a client asking for something exotic.

What the spec says

RFC 9110 §15.5.7 defines 406 for the case where the target resource has no representation acceptable per the proactive negotiation fields and the server is unwilling to supply a default. It SHOULD generate content listing the available representation characteristics so the agent can choose, a requirement essentially nobody implements. The negotiation machinery is §12, with proactive negotiation in §12.1, quality values in §12.4.2, and the Accept field in §12.5.1. One trap: an unsatisfiable Content-Type on a request body is 415, not 406. If you do negotiate, set Vary or caches will hand the wrong representation to the wrong client.

What actually causes it

Most 406s are self-inflicted server strictness. Rails renderers, Spring’s content negotiation, and several API gateways will 406 rather than serve JSON to a client that asked for something else, even when JSON is all the endpoint produces. Clients contribute by sending a narrow Accept with no */* fallback — an SDK hardcoding a versioned vendor media type gets 406 the moment the server drops that version. The subtler cause is intermediaries: some corporate proxies and older CDNs rewrite Accept, so the header the server negotiates against is not the one the client sent. Language negotiation does it too, when an English-only site is asked for Accept-Language: fr with no wildcard.

How to debug it

Prove it is negotiation by removing negotiation. Send the most permissive header possible and see whether the failure disappears:

curl -si -H 'Accept: */*' https://api.example.com/v1/items | head -1
curl -si -H 'Accept: application/xml' https://api.example.com/v1/items | head -1

If */* succeeds and the specific type fails, the server genuinely cannot produce that media type and the fix belongs on the client. If both fail, the header is not the cause and you are chasing the wrong error.

Next confirm the server sees what you sent, since proxies rewrite this header freely. Check the response for Vary too: a negotiating endpoint without Vary: Accept produces cache poisoning that looks like intermittent 406s for a subset of users. The header inspector shows the difference between sent and received headers.

Working examples

curl

# Widest possible Accept — if this works, the client header is the problem.
curl -si -H 'Accept: */*' https://api.example.com/v1/items | head -1

# The specific type that failed.
curl -si -H 'Accept: application/xml' https://api.example.com/v1/items | head -1

# Does the endpoint declare what it varies on?
curl -sD - -o /dev/null https://api.example.com/v1/items | grep -iE '^(vary|content-type)'

Python (requests)

import requests

url = "https://api.example.com/v1/items"

for accept in ("*/*", "application/json", "application/xml", "text/html"):
    r = requests.get(url, headers={"Accept": accept}, timeout=10)
    print(f"{accept:20} {r.status_code} {r.headers.get('Content-Type')}")

# requests sends Accept: */* by default, so a 406 from your app but not from
# curl usually means a client library narrowed the header for you.
print(requests.get(url, timeout=10).request.headers["Accept"])

Node (fetch)

const url = "https://api.example.com/v1/items";

for (const accept of ["*/*", "application/json", "application/xml"]) {
  const res = await fetch(url, { headers: { Accept: accept } });
  console.log(accept.padEnd(18), res.status, res.headers.get("content-type"));
}

// Negotiating endpoints must advertise Vary or caches will mix responses.
const res = await fetch(url, { headers: { Accept: "application/json" } });
console.log("vary:", res.headers.get("vary"));

Reference and tooling

The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.

Other 4xx codes