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/itemsWhat 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
- 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 405 Method Not Allowed
- 407 Proxy Authentication Required
- 408 Request Timeout
- 409 Conflict
- 410 Gone
- 411 Length Required
- 412 Precondition Failed
- 413 Content Too Large
- 414 URI Too Long
- 415 Unsupported Media Type
- 416 Range Not Satisfiable
- 417 Expectation Failed
- 418 (Unused)
- 419 Page Expired
- 421 Misdirected Request
- 422 Unprocessable Content
- 423 Locked
- 424 Failed Dependency
- 425 Too Early
- 426 Upgrade Required
- 428 Precondition Required
- 429 Too Many Requests
- 431 Request Header Fields Too Large
- 444 No Response
- 451 Unavailable For Legal Reasons
- 494 Request Header Too Large
- 495 SSL Certificate Error
- 496 SSL Certificate Required
- 497 HTTP Request Sent to HTTPS Port
- 499 Client Closed Request