A 302 turns your POST into a GET
published
TL;DR
301, 302 and 303 let a client rewrite your POST into a GET and throw the body away. 307 and 308 do not. If an endpoint you POST to redirects — http to https, a trailing slash, an old path — the payload can vanish while the transfer still ends in 200.
The problem
You POST JSON to an API and the server behaves as if the body were empty. Validation fails, or worse, a listing endpoint answers cheerfully:
POST /api/v1/orders → 302 Found, Location: /api/v1/orders/
GET /api/v1/orders/ → 200 OK, []
Two requests went out. The second one is a GET, has no body, and returns 200. Any client that only checks res.ok records a success. Webhook senders are the worst version of this: they POST once, get followed to a GET, log 200, and never retry — the payload is gone and both sides think delivery worked.
The redirect is often not one you wrote. A load balancer normalising trailing slashes, an http to https upgrade, a CDN canonical-host rule, or a framework’s “add the missing slash” default all emit 301 by reflex.
Why it happens
It is in the spec, and it is deliberate. RFC 9110 says of both 301 and 302:
For historical reasons, a user agent MAY change the request method from POST to GET for the subsequent request.
303 See Other goes further — the client is told to fetch the target with GET. Only 307 and 308 guarantee the method and body survive.
| Status | Method preserved? | Body preserved? | Meaning |
|---|---|---|---|
| 301 Moved Permanently | No — POST may become GET | No | Permanent new URL |
| 302 Found | No — POST may become GET | No | Temporary new URL |
| 303 See Other | No — becomes GET by design | No | Fetch the result elsewhere |
| 307 Temporary Redirect | Yes | Yes | Temporary, method-safe |
| 308 Permanent Redirect | Yes | Yes | Permanent, method-safe |
“MAY” is what makes this expensive to debug: two clients hitting the same endpoint can disagree. In practice the major ones all rewrite. The Fetch standard’s redirect algorithm sets the method to GET and the body to null for 301/302 after a POST, and for 303 on anything except GET and HEAD — so browsers, fetch() in Node, and everything built on undici behave the same way. curl -L does the same rewrite for 301, 302 and 303.
There is a second, quieter loss. When a redirect crosses origins, the Fetch standard strips the Authorization header. So a redirect can turn an authenticated POST into an anonymous GET, and the 401 you get back sends you hunting for a token bug that does not exist.
What to do
See it first. curl will tell you what the final request actually was:
curl -sS -L -d 'id=1' -o /dev/null \
-w 'final: %{method} %{response_code} %{url_effective}\n' \
https://api.example.com/v1/orders
If that prints final: GET, you found it. To confirm the rewrite is the cause, tell curl to keep POSTing across the redirect:
curl -sS -L --post301 --post302 --post303 -d 'id=1' \
-w 'final: %{method} %{response_code}\n' \
https://api.example.com/v1/orders
Fix the URL, not the client. The redirect costs a round trip on every call even when it works. Point the client at the exact canonical URL — right scheme, right host, right trailing slash.
If you own the server and must redirect an API, use 308. In Express:
app.post('/api/v1/orders', (req, res) => {
res.redirect(308, '/api/v2/orders');
});
In nginx, for a host that serves an API, prefer the method-safe code:
return 308 https://$host$request_uri;
Make the client refuse instead of guessing. For anything with a body, do not follow redirects silently:
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
redirect: 'manual',
});
if (res.status >= 300 && res.status < 400) {
throw new Error(`refusing to follow ${res.status} to ${res.headers.get('location')}`);
}
A thrown error at the redirect beats a 200 that silently did nothing.
Caveats
redirect: 'manual'is not the same everywhere. In Node and undici you get the real3xxresponse and can readLocation. In a browser you get an opaque-redirect response withstatus0and no headers — you can detect that a redirect happened, but not where it pointed.307/308preserve the method, but the body still has to be re-sendable. Streaming a body from a pipe and curl may stop withnecessary data rewind wasn't possible, because it cannot replay what it already consumed.- Cross-origin redirects drop
Authorizationeven on307/308. Method-safe is not credential-safe. - This is about redirects on the request you send, not about GET redirects, which are fine and normal.
- Very old clients predate
308(standardised in 2015 by RFC 7538). Anything current handles it; a hand-rolled HTTP client from a decade ago might not.