Quick verdict: HTTP 415 (Unsupported Media Type) means the server rejected your request because the Content-Type header didn't match what the endpoint accepts. Most common causes: posting JSON without Content-Type: application/json, uploading a file in a format the server doesn't allow, or having a proxy or WAF strip the Content-Type. Fix the request — don't retry with backoff.
This guide covers what 415 means at the protocol level, the six causes that produce 99% of 415s in API and scraping workflows, copy-paste fixes for the four most common HTTP libraries (curl, Python requests, axios, fetch), and how 415 differs from 400 and 422.
RFC 9110 defines 415 as: "the origin server is refusing to service the request because the content is in a format not supported by this method on the target resource." In practice, this means:
Content-Type header.Two important properties: 415 is a permanent client error (retrying with the same headers will fail every time), and it happens before body parsing (so the server hasn't seen your actual data yet).
Content-Type header. Some libraries default to application/x-www-form-urlencoded when none is set; if the API wants JSON, you get 415.Content-Type value. Posting JSON with Content-Type: text/plain is the textbook 415.charset parameter. Some strict servers reject application/json; charset=utf-8 if they only accept application/json with no parameters.Accept header. Less common, but some servers (especially RESTful APIs) check Accept and 415 if you ask for a response format they don't produce.The default for curl --data is application/x-www-form-urlencoded. For JSON APIs, set the header explicitly:
curl -X POST https://api.example.com/v1/items -H 'Content-Type: application/json' -H 'Accept: application/json' --data-raw '{"name":"sample","price":42}'
Use the json= parameter (which sets Content-Type for you) instead of data= with a string:
import requests
# Correct — requests sets Content-Type: application/json automatically
r = requests.post("https://api.example.com/v1/items", json={"name": "sample", "price": 42})
# Also correct — explicit
r = requests.post(
"https://api.example.com/v1/items",
data='{"name":"sample","price":42}',
headers={"Content-Type": "application/json", "Accept": "application/json"},
)
// axios sets application/json automatically when you pass an object
await axios.post("https://api.example.com/v1/items", { name: "sample", price: 42 });
// Override explicitly if needed
await axios.post(url, body, { headers: { "Content-Type": "application/json" } });
// fetch does NOT set Content-Type automatically — you must set it
await fetch("https://api.example.com/v1/items", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ name: "sample", price: 42 }),
});
| Status | Meaning | Stage of processing |
|---|---|---|
| 400 Bad Request | Request is malformed — bad JSON syntax, missing required field | Body parsing failed |
| 415 Unsupported Media Type | Content-Type header doesn't match endpoint | Before body parsing |
| 422 Unprocessable Entity | Body parses fine, but data fails validation | After parsing, during business logic |
Order of operations: server receives request → checks Content-Type (415 if mismatch) → parses body (400 if syntax error) → validates business rules (422 if invalid).
When scraping API endpoints behind a residential or datacenter proxy, two extra causes of 415 to watch for:
text/plain. Use a premium residential proxy or static datacenter proxy that passes headers through unmodified, and verify with SpyderProxy's HTTP headers tool.If you're seeing 415 specifically when scraping behind a proxy but not when running the same request directly, the proxy is rewriting headers. Switch to a higher-trust proxy tier or use HTTPS end-to-end so the WAF can't read or modify the body.