415 Unsupported Media Type (tested)

A 415 Unsupported Media Type means the server refused the request body because of its Content-Type (or its charset or Content-Encoding). Almost always the body is valid JSON and the header is wrong or missing. We sent the same {"a":1} from five clients and into three frameworks: only Flask returned 415 for a missing JSON header; Express and FastAPI failed differently, which is why this bug is easy to misread.

1. What each client sends by default

Logged by a Node.js echo server. The body was always the JSON text {"a":1} unless noted:

Client callContent-Type received
Node 24 fetch(url, {method:'POST', body: JSON.stringify(obj)})text/plain;charset=UTF-8
Chromium 153 fetch, same calltext/plain;charset=UTF-8
fetch with body: new URLSearchParams({a:'1'})application/x-www-form-urlencoded;charset=UTF-8
fetch with body: new FormData()multipart/form-data; boundary=...
fetch with body: new Blob(['{}'])(none sent)
curl -d '{"a":1}'application/x-www-form-urlencoded
curl --json '{"a":1}'application/json (and Accept: application/json)
Python requests post(url, data='{"a":1}') (a string)(none sent)
Python requests post(url, data={'a': 1}) (a dict)application/x-www-form-urlencoded, body a=1
Python requests post(url, json={'a': 1})application/json

So fetch plus JSON.stringify without a header, curl -d, and requests data= with a string all send JSON labelled as something else. The client fixes:

// fetch
fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(obj)});

# curl (measured with 8.14.1)
curl --json '{"a":1}' https://api.example.com/x
# or
curl -H 'Content-Type: application/json' -d '{"a":1}' https://api.example.com/x

# Python requests
requests.post(url, json=obj)

2. Flask 3.1.3: real 415

Content-Type sentrequest.get_json() / request.jsonget_json(silent=True)get_json(force=True)
application/json200, parsedparsedparsed
application/vnd.api+json, application/problem+json200, parsedparsedparsed
text/plain;charset=UTF-8415Noneparsed
application/x-www-form-urlencoded415Noneparsed
(none sent)415Noneparsed

The 415 page says: Did not attempt to load JSON data because the request Content-Type was not 'application/json'. Flask accepts any application/*+json type (request.is_json was True for both). Malformed JSON with the right header is a different error: 400 Bad Request. Fix the client; use force=True only if you must accept clients you cannot change.

3. Express 5.2.1: no 415, just an empty body

express.json() skips any request whose type does not match and calls the next handler with req.body left undefined:

Requestexpress.json()
application/json or application/json; charset=utf-8200, req.body = {a: 1}
text/plain;charset=UTF-8 (fetch default)200, req.body undefined
application/x-www-form-urlencoded (curl -d)200, req.body undefined
application/vnd.api+json200, req.body undefined (unlike Flask)
No body at all200, req.body undefined
application/json; charset=latin1415, unsupported charset "LATIN1" (type charset.unsupported)
Content-Encoding: foo415, unsupported content encoding "foo"

The usual symptom is therefore a 500, not a 415: a handler doing const {a} = req.body threw TypeError: Cannot destructure property 'a' of 'req.body' as it is undefined. Three fixes, all measured:

// a) Accept extra types as JSON (text/plain then parsed to {a: 1})
app.use(express.json({type: ['application/json', 'text/plain']}));

// b) Reject wrong types explicitly with a real 415
app.post('/items', (req, res, next) => {
  if (!req.is('application/json'))
    return res.status(415).json({error: 'Content-Type must be application/json'});
  next();
}, express.json(), handler);

// c) Fix the client (section 1) - the right answer when you own both sides

Option (b) returned 415 for text/plain, form-urlencoded and a missing header, and 200 for application/json; charset=utf-8. For application/*+json types use express.json({type: ['application/json', 'application/*+json']}).

4. FastAPI 0.142.2: 422, not 415

Content-Type sentPydantic model or dict = Body() parameterawait request.json()
application/json, application/vnd.api+json200200
application/json; charset=latin1200 (charset ignored)200
text/plain, form-urlencoded or none422200 (parses anyway)

The 422 body is easy to misread because it shows your JSON as a string: {"type":"model_attributes_type","loc":["body"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"{\"a\":1}"}. If input is a quoted string of your JSON, the Content-Type was wrong; fix it on the client.

5. Browsers: the JSON header triggers a CORS preflight

In Chromium 153, from http://127.0.0.1:8985 to http://localhost:8986 (an API that sent Access-Control-Allow-Origin: *):

fetch POSTRequests the server sawResult
no header (body sent as text/plain;charset=UTF-8)POST only200
Content-Type: text/plainPOST only200
Content-Type: application/json, no Access-Control-Allow-HeadersOPTIONS with Access-Control-Request-Headers: content-type, then nothingTypeError: Failed to fetch
Content-Type: application/json, server allows Content-TypeOPTIONS, then POST200

The console said: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response. Do not "fix" a 415 by dropping the header to avoid the preflight; that just moves you to the Express or FastAPI failure above. Answer the OPTIONS request with Access-Control-Allow-Headers: Content-Type instead.

6. Checklist

For the JSON header itself see Content-Type: application/json, and for charset decoding see Content-Type charset=utf-8.

Frequently asked questions

What causes 415 Unsupported Media Type?

The server rejected the body because of its Content-Type, charset or Content-Encoding. In our tests Flask 3.1.3 returned 415 for text/plain, form-urlencoded or a missing header on request.get_json(), and Express 5.2.1 returned 415 for charset=latin1 or Content-Encoding: foo.

Why does fetch with JSON.stringify get a 415?

Without a headers option, fetch in Node 24 and Chromium 153 sent the string as text/plain;charset=UTF-8. Add headers: {'Content-Type': 'application/json'}.

What Content-Type does curl -d send?

curl 8.14.1 sent application/x-www-form-urlencoded for -d. curl --json sent application/json and Accept: application/json.

Why is req.body undefined in Express instead of a 415?

express.json() in Express 5.2.1 skips requests whose Content-Type is not application/json and leaves req.body undefined with no error. Fix the client, widen the type option, or check req.is('application/json') and return 415 yourself.

Does FastAPI return 415 for the wrong Content-Type?

Not in FastAPI 0.142.2: a Pydantic body sent as text/plain or without a Content-Type returned 422 with the JSON shown as a string in the input field. request.json() parsed it anyway.

Does Content-Type: application/json trigger a CORS preflight?

Yes. Chromium 153 sent an OPTIONS request with Access-Control-Request-Headers: content-type, and the POST failed unless the server replied Access-Control-Allow-Headers: Content-Type.

Written with AI assistance. Measured on 2026-10-02 with Node.js 24.21.0 (built-in fetch, and an http.createServer echo server on localhost:8985-8986 that logged the Content-Type of every request), curl 8.14.1, Python 3.13.5 with requests 2.32.3, Express 5.2.1 (body-parser 2.3.0), Flask 3.1.3 (Werkzeug 3.1.9), FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5, uvicorn 0.54.0) and headless Chromium 153.0.8010.12 (Playwright) for the CORS preflight test. Every request body was the same JSON string {"a":1}; only the Content-Type changed. Spring and ASP.NET Core were not run here. PlainHeaders is our extension; Node.js, curl, Python, Express, Flask, FastAPI and Chromium are free.