HookSteadBETA

Fixing Stripe webhook signature verification errors

“No signatures found matching the expected signature for payload”: the five causes, how to spot each one, and how to fix them in Node, Express and Next.js.

7 min read · Updated

The error

No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?

Stripe's libraries throw this when the HMAC they compute doesn't match the Stripe-Signature header. Stripe signs {timestamp}.{raw body} with your endpoint's signing secret, so the mismatch always comes from one of three inputs: the body, the secret or the timestamp.

Cause 1: the body was parsed before you verified it

By far the most common. A JSON body parser runs first, your code passes JSON.stringify(req.body) or the parsed object, and the bytes differ from what Stripe signed: whitespace, key order or unicode escapes change.

Express: give the webhook route a raw body parser, and register it before any global express.json():

ts
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], secret);
  // …
  res.sendStatus(200);
});

Next.js App Router: read the body as text, not JSON:

ts
export async function POST(req: Request) {
  const body = await req.text();
  const event = stripe.webhooks.constructEvent(body, req.headers.get("stripe-signature")!, secret);
  return new Response(null, { status: 200 });
}

Cause 2: the wrong signing secret

Every endpoint has its own secret, and so do test mode, live mode and the Stripe CLI. Check that:

  • the secret starts with whsec_ (not sk_ or rk_, which are API keys);
  • it belongs to this endpoint, in the same mode as the event;
  • locally, you use the secret printed by stripe listen, not the dashboard one. See testing webhooks locally.

Cause 3: the timestamp is too old

Stripe's libraries reject signatures older than five minutes by default, to stop replay attacks. You'll see this when your server's clock is wrong, or when you replay a captured Stripe request later yourself. Fix the clock with NTP. Don't raise the tolerance to make old requests pass.

Cause 4: something rewrote the request on the way

A proxy, API gateway or serverless adapter that decompresses, re-encodes or "normalizes" JSON changes the body. Log the length and a hash of the body you receive and compare it with the event in Stripe's dashboard.

Cause 5: a rolled secret

After you roll an endpoint's secret in Stripe, the old one keeps working only for the overlap period you chose. Update your configuration before it ends.

How to debug it in two minutes

  1. Log the length of the body you pass to constructEvent, and its type: it must be a string or a Buffer, never an object.
  2. Log the first few characters of the secret (never the whole thing) and compare with the endpoint in Stripe.
  3. Resend the event from Stripe's dashboard and check the time difference between the header's t= value and your server's clock.

Or let HookStead verify Stripe for you

Point Stripe at a HookStead source with the "Stripe signature" option. HookStead verifies Stripe's signature on the raw bytes as the event arrives, stores it, and forwards it to your app signed with its own Standard Webhooks signature, freshly computed on every attempt. Retries and replays a day later still verify, which Stripe's five-minute signatures can't. See the Stripe integration and the step-by-step guide.