You pasted your webhook URL into the Stripe dashboard. Stripe sent a test event. Your server returned 400 with:
No signatures found matching the expected signature for payload
You triple-checked the signing secret. It's right. So now what?
The three real causes
In ~95% of debugging sessions, the cause is one of:
- Body mutation — your framework parsed the JSON before signature verification, and the re-serialized body has different whitespace than what Stripe signed.
- Header parsing — you treated the whole
Stripe-Signatureheader as the signature, instead of pulling thev1=...part. - Stale timestamp —
t=...is more than 5 minutes old (Stripe rejects by default).
The wrong-secret case exists, but it's rare in practice — Stripe shows the secret on the dashboard verbatim and most people copy-paste once and never touch it.
Body mutation, the silent killer
Stripe computes the signature over <unix-timestamp>.<exact-bytes-of-the-request-body>. The exact bytes. If your code looks like this in Express:
app.use(express.json())
app.post('/webhook', (req, res) => {
// req.body is a parsed object — the raw bytes are gone
const sig = req.headers['stripe-signature']
stripe.webhooks.constructEvent(req.body, sig, secret) // ← throws
})
You have a problem: req.body is no longer the bytes Stripe signed. It's whatever JSON.stringify produces when you re-serialize, which has different whitespace, different key ordering, different number formatting for some edge cases.
The fix is to consume the raw body for the webhook route only:
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature']
const event = stripe.webhooks.constructEvent(req.body, sig, secret)
// req.body is now a Buffer — exactly what Stripe signed
},
)
Same shape in other frameworks: Django needs request.body (the raw bytes), not request.data. Rails needs request.raw_post. FastAPI needs await request.body(), not the Pydantic-parsed model.
Verify locally before re-deploying
Before changing your handler, capture a real webhook locally and verify the signature in isolation. If it passes locally, your handler code is the issue; if it fails, the body or secret is wrong.
We made a free verifier for exactly this — pick Stripe, paste body + header + secret, and the page tells you in plain English what mismatched. Pure client-side; the secret stays in your browser.
For capturing a real payload, the webhook tester gives you a one-shot URL that captures whatever Stripe sends. Forward the test event from the Stripe dashboard at the tester URL, copy the body and Stripe-Signature header back into the verifier.
Let your local handler actually run
Once you've verified the signature checks out, you want the actual handler to run on your laptop — not just inspect payloads. The classic problem with that workflow is that ngrok rotates the URL on every restart, so you re-paste it into Stripe daily. lrok solves this with a reserved subdomain on the free plan:
$ lrok reserve stripe-dev
$ lrok http 4242 --hint stripe-dev
Forwarding https://stripe-dev.lrok.io → http://127.0.0.1:4242
Paste https://stripe-dev.lrok.io/webhook into the Stripe dashboard once. The URL stays put across restarts, machines, and weeks of dev. The full walkthrough is at /use-case/stripe-webhooks.
The 5-minute checklist
When the next signature mismatch happens, run through this in order:
- Is the body raw? Log
typeof bodyin your handler — should beBufferorbytes, notobject. - Are you stripping
v1=from the header? The Stripe SDK does this for you; if you wrote your own verifier, this is the second-most-common cause. - Is the timestamp fresh?
t=...should be within 300 seconds of now. Replays from the dashboard are stamped with the original time — re-fire the test event for a fresh timestamp. - Cross-check with the verifier. Paste body + header + secret; if it matches there, your handler is the issue, not the secret.
Five minutes, one of those is the answer.