Webhook Debugging Guide

Stripe webhook signature verification failed: "No signatures found matching the expected signature for payload"

Short answer

Stripe's SDK throws a signature verification error for one of three reasons: the wrong secret, a request body that was altered before verification, or a timestamp older than the tolerance window. The error text tells you which:

No signatures found matching the expected signature for payload

means the computed signature didn't match at all: wrong secret, or a body that isn't byte-for-byte what Stripe sent. A separate message, Timestamp outside the tolerance zone, means the signature was valid but too old; that one has its own guide.

Check the secret first

Each endpoint in the Stripe dashboard has its own signing secret, starting with whsec_. It is not your API key. If you're testing locally with the CLI:

stripe listen --forward-to localhost:3000/webhook

the CLI prints its own temporary whsec_ secret for that session — use that one, not your dashboard endpoint's secret, while stripe listen is running.

The other common cause: a re-parsed body

Stripe signs the exact bytes it sent. If a global JSON body parser (Express's express.json(), body-parser) runs before your webhook route, it replaces the raw body with a re-serialized object, which almost never matches byte-for-byte, and verification fails even with the right secret. Mount a raw parser for the webhook route specifically, before any global JSON middleware:

app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const event = stripe.webhooks.constructEvent(
      req.body, // Buffer, not parsed JSON
      req.headers['stripe-signature'],
      endpointSecret
    );
    res.sendStatus(200);
  }
);

Timestamp tolerance

Stripe rejects a signature whose t= timestamp is more than 300 seconds (5 minutes) from the current time, to stop a captured request from being replayed indefinitely. This is by design: if you're testing by pasting an old payload back in, it will fail even with a correct secret. constructEvent takes an optional fourth argument to widen the tolerance for local testing, but the fix for a real replay is to re-sign it with a current timestamp — see the timestamp replay guide.

The easier way: WebhookMon

WebhookMon verifies every event as it arrives and states the verdict in a sentence — wrong secret, stale timestamp, or a malformed header — instead of a stack trace to decode. When it's the timestamp, its Replay button can re-sign with the current time in one click, because it holds your endpoint's secret.

WebhookMon's Signature tab for a Stripe invoice.paid event: Signature timestamp is 12 minutes old, outside the 5-minute tolerance, with the stripe-signature header, the timestamp and the configured secret
Download WebhookMon free trial Learn more

Related guides