// blog

← all posts

Why your Stripe webhook signature doesn't match — and the cleanest fix

·6 min read·stripe · webhooks · debugging

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:

  1. Body mutation — your framework parsed the JSON before signature verification, and the re-serialized body has different whitespace than what Stripe signed.
  2. Header parsing — you treated the whole Stripe-Signature header as the signature, instead of pulling the v1=... part.
  3. Stale timestampt=... 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:

  1. Is the body raw? Log typeof body in your handler — should be Buffer or bytes, not object.
  2. 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.
  3. 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.
  4. 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.

// try it yourself

lrok is a hosted reverse-tunnel: one command, public HTTPS URL, reserved subdomain on the free plan. $9/mo flat for unlimited.