Why Your Stripe Webhook Returns 400 in Production (and How to Fix It)
Your Stripe webhook works perfectly on localhost. You deploy. It returns 400 Bad Request on every event. You re-check the webhook secret. You re-paste it. Nothing. Below: the four root causes, ranked by how often I've seen them break indie-hacker setups, and the working code that fixes each.
1. The body parser ate your raw bytes (80% of cases)
Stripe computes the HMAC-SHA256 signature against the raw body bytes of the POST. Not the JSON-parsed object. The bytes themselves, as they came over the wire.
Most modern serverless runtimes (Vercel, Netlify, Cloudflare Workers) auto-parse the JSON body before your handler ever sees it. The bytes get serialized back to a string at some point — often with whitespace differences, key ordering shifts, or encoding changes — and the recomputed HMAC no longer matches Stripe's.
Result: crypto.timingSafeEqual returns false. You return 400. Stripe retries forever.
Fix for Next.js API routes (Pages router):
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req, res) {
const rawBody = await readRawBody(req);
// verify against rawBody, NOT against req.body
}
function readRawBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
Fix for Next.js App Router:
// app/api/stripe-webhook/route.ts
export async function POST(req: Request) {
const rawBody = await req.text(); // text() preserves bytes
// verify against rawBody
}
Fix for Hono on Cloudflare Workers:
app.post('/webhook', async (c) => {
const rawBody = await c.req.text();
// verify against rawBody
});
JSON.stringify(req.body). If they differ even by one character, you've found it.
2. You used === for the signature compare (10% of cases)
Even when your raw body is right, comparing the computed HMAC hex against the header signature with === leaks timing information. An attacker who can hit your endpoint repeatedly can use that timing to forge signatures byte-by-byte.
More urgently for the 400 issue: === on Buffer types in Node will always return false (Buffers compare by reference, not content). If you wrote if (sigBuffer === expectedBuffer), the compare always fails, no matter what the bytes are.
Fix:
const expBuf = Buffer.from(expected, 'hex');
const sigBuf = Buffer.from(signature, 'hex');
if (expBuf.length !== sigBuf.length) return false;
return crypto.timingSafeEqual(expBuf, sigBuf);
Always check length first — timingSafeEqual throws if the buffers don't match length. Bail early.
3. The timestamp tolerance check rejected an old retry (5% of cases)
Stripe's signature header looks like:
stripe-signature: t=1778120423,v1=abc123...
The t= is a Unix timestamp. Stripe expects you to reject anything more than 5 minutes old, as a replay-attack guard.
If you implemented this check (good), and Stripe is now retrying a webhook delivery from yesterday because earlier deliveries failed (also normal — Stripe retries for up to 3 days), the timestamp is stale and you correctly reject it with 400.
This is a feature, not a bug. The 400s in your log are Stripe trying to deliver old failed events that you can't recover. Once your sig verification is working for new events, the old retries will eventually time out and the noise will fade in 72 hours.
How to confirm:
// Log the timestamp delta from now
const now = Math.floor(Date.now() / 1000);
console.log('sig timestamp delta:', now - timestamp, 'seconds');
If the delta is in the thousands of seconds, you're seeing retries of stale events. Your fix worked; the noise will subside.
4. Stripe is sending events the wrong webhook secret can verify (3% of cases)
You have two webhook endpoints in Stripe (development and production), each with its own whsec_. Or you rotated the secret without updating the env var. Or you copied the wrong endpoint's secret.
Result: every event arrives signed with one secret, your endpoint verifies against another, every event 400s.
Fix:
- Stripe Dashboard → Developers → Webhooks → click your endpoint
- "Signing secret" → reveal → copy the full
whsec_*** - Set
STRIPE_WEBHOOK_SECRETin your Vercel/Cloudflare/Render production env vars to that exact value - Redeploy (env vars don't hot-reload)
- Stripe Dashboard → Webhooks → click endpoint → "Send test event" → checkout.session.completed → verify 200 in your logs
The "always return 200" rule (no matter what)
Even when your sig verification is solid, your webhook can still fail downstream — your email send times out, your DB write hits a deadlock, your tier-mapping crashes on an unexpected product ID.
If you return 500 to Stripe in those cases, Stripe retries the event. And retries. And retries. Forever, on a 5-min / 1-hr / 2-hr / 4-hr / 8-hr backoff. Your endpoint gets hammered. Your error log floods. Eventually, Stripe disables your endpoint entirely and you stop receiving live events.
Better:
try {
await sendCustomerEmail(...);
await writeToDb(...);
} catch (e) {
console.error('webhook downstream failed', e);
// Tell Stripe we got it. We'll handle the retry ourselves.
return res.status(200).json({ received: true, error: String(e) });
}
return res.status(200).json({ received: true });
Pair this with an error monitor (Sentry, Logtail, your own dashboard) that watches for the webhook downstream failed log line and alerts you. You retry the email send / DB write yourself, on your own schedule, with idempotency keyed on the Stripe event ID.
The full working code
The api/stripe-webhook.js running on this site implements all four fixes above plus tier-specific email dispatch via Resend. It's been verified live with a real $29 Founding 100 checkout — six events fired, all 200 OK in under one second, customer + founder emails delivered.
Open source: github.com/bshelby88/sentry-forge-landing/api/stripe-webhook.js
Free to fork. MIT-style copy-it-and-don't-tell-me license.
Want it shipped to your repo in 24 hours instead?
$199 flat (first 3 clients, then $399). One file in your repo, sig verification, customer + founder emails via Resend, deployed and verified with a $1 live test.
See the offer →Quick troubleshooting checklist
- [ ]
bodyParser: falseset on the route? - [ ] Reading raw bytes (
req.text(),req.on('data')), notreq.body? - [ ] Using
crypto.timingSafeEqualagainst equal-length Buffers? - [ ] 5-min timestamp tolerance window in place?
- [ ] Webhook secret in env var matches the endpoint's secret in the Stripe dashboard?
- [ ] Returning 200 even when downstream fails?
- [ ] Error log catching downstream failures separately?
If everything above is yes and you're still getting 400s, the events you're seeing are Stripe retrying old events that failed before your fix landed. Wait 72 hours for the retry queue to drain.
If everything above is yes and new events are still 400ing, post the contents of your verifySignature function in a Stripe support ticket. There's likely a Buffer encoding mismatch I haven't covered.
Or: I'll wire it for you in 24h.
$199, first 3 clients only. Includes 7-day support window for any sig-verification or email-delivery bug in my code.
Buy — $199