Stripe webhook 400s: you verified the re-serialized body, not the raw payload
Problem
Every webhook delivery failed signature verification:
StripeSignatureVerificationError: No signatures found matching the expected signature for payload
The signing secret was correct (copy-pasted from the endpoint definition in the dashboard). The timestamp tolerance was not the issue either. Every single event, local CLI forwarding and production alike, bounced.
Root cause
The signature Stripe sends is an HMAC over the EXACT bytes of the request body. Our handler parsed the body first, then verified against JSON.stringify(parsed). Re-serializing reorders nothing in our case, but it rewrites whitespace, drops/normalizes escaped characters, and re-formats numbers, so the bytes no longer match what Stripe hashed. One byte of difference, one failed HMAC. This also happens when any middleware calls req.json() before your handler sees the body, or when a proxy recompresses the payload.
Fix
Verify against the raw text, before anything touches the body. On Workers (Hono), use the async verifier, the sync one is Node-only because it relies on crypto.timingSafeEqual:
app.post('/api/stripe/webhook', async (c) => {
const payload = await c.req.text(); // raw bytes, before any json()
const sig = c.req.header('stripe-signature') ?? '';
let event;
try {
event = await stripe.webhooks.constructEventAsync(payload, sig, secret);
} catch {
return c.json({ error: 'invalid signature' }, 400);
}
if (event.type === 'checkout.session.completed') {
// handle
}
return c.json({ received: true });
});
Checklist when this error survives the fix above:
- The secret must be the one from the webhook ENDPOINT you actually configured,
whsec_..., not a key from the API settings page. - Test mode and live mode events carry different signatures; an endpoint registered in the dashboard as test-only will reject live payloads and vice versa.
- If you proxy through a framework that consumes the body, read the raw text at the earliest middleware and pass it down rather than re-reading.
One raw read, one verifier. Nothing else should touch the payload first.