ARLing

Notes

Stripe "No signatures found matching the expected signature": twelve causes, one at a time

Andrej Lauko, ARLing · 6 September 2026

Stripe webhook signature verification fails with this exact message when constructEvent() rejects a webhook request outright, before your code ever reads the event:

Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.

constructEvent() checks exactly three inputs: the raw request body, the whsec_ signing secret, and the Stripe-Signature header. It fails the same way on Next.js and on plain Express, usually because some layer before your handler already parsed or rewrote the raw body. Below are the twelve causes, one at a time.

01

The body already arrived as parsed JSON

Something upstream, a global body parser, a logging proxy, an API gateway, parsed the request body into a JavaScript object before constructEvent() ran. Stripe's docs are direct: "Stripe requires the raw body of the request to perform signature verification... Any manipulation to the raw body of the request causes the verification to fail." A parsed object is not the raw body, even when its content looks identical.

Fix

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

02

The body was parsed, then re-serialized

Some handlers call JSON.parse() on the body, do something with it, then call JSON.stringify() on the result before passing it to constructEvent(). Re-serializing can change whitespace or key order versus the exact bytes Stripe signed. The HMAC comparison fails even though the data looks identical.

Fix

// wrong: constructEvent(JSON.stringify(JSON.parse(raw)), sig, secret)
const event = stripe.webhooks.constructEvent(raw, sig, secret); // keep the original bytes

03

A global body parser runs before the webhook route

In Express, app.use(express.json()) mounted ahead of the webhook route consumes and parses the stream first. Stripe's troubleshooting docs single this out: "the order of middleware configuration matters." Scope raw-body parsing to the webhook route alone, mounted before any global parser.

Fix

app.post('/webhook', express.raw({ type: 'application/json' }), webhookHandler);
app.use(express.json()); // every other route, mounted after

04

Next.js App Router: req.json() instead of req.text()

A route handler under app/api/.../route.ts that calls await req.json() has already parsed the body. Stripe's own Next.js example in the stripe-node repository reads it with req.text() specifically, so constructEvent() gets the untouched string.

Fix

export async function POST(req) {
  const body = await req.text(); // not req.json()
  const event = stripe.webhooks.constructEvent(
    body, req.headers.get('stripe-signature'), secret
  );
}

05

Missing await on req.text()

req.text() returns a Promise. Without await, constructEvent() receives the pending Promise, not a resolved string. That produces a different error, "Webhook payload must be provided as a string or a Buffer", so if that is what you see, check this line.

Fix

const body = await req.text();

06

Next.js Pages Router: default body parser not disabled

Pages Router API routes parse the body as JSON by default. Without export const config = { api: { bodyParser: false } }, Next has already consumed and parsed the raw bytes before your handler runs.

Fix

export const config = { api: { bodyParser: false } };

async function buffer(req) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  return Buffer.concat(chunks);
}

07

The secret is an API key, not the signing secret

The value stored as STRIPE_WEBHOOK_SECRET sometimes starts with sk_ or pk_, an API key copied from the wrong place. Stripe's Dashboard docs describe the real thing plainly: "a signing secret beginning with whsec_ appears" on the endpoint's own settings page. An API key never verifies a webhook signature.

Fix

Copy the whsec_... value from Dashboard, Webhooks, your endpoint, Reveal secret, or from the stripe listen terminal output for local testing.

08

The secret variable never reaches the handler

If the environment variable holding the secret is unset, misspelled, or simply missing in this deployment environment, constructEvent() throws the same "No signatures found" error as a wrong secret. Stripe's library does not distinguish an empty secret from an incorrect one.

Fix

Log the first few characters of the secret at boot (never the whole value) to confirm it actually loaded in this environment, not just on your machine.

09

Using the CLI's forwarding secret against a live endpoint

The whsec_ value printed by stripe listen is generated for local forwarding, not for any registered endpoint. Stripe's docs: "Don't verify signatures on events forwarded by the CLI using the secret from a Dashboard-managed endpoint, or the other way around." Every endpoint and mode, test and live, has its own secret.

Fix

Use the whsec_ secret from Dashboard, Webhooks, the specific live-mode endpoint that is actually receiving the request.

10

Reading the wrong header

Stripe only ever sends the signature in the Stripe-Signature header (Node normalizes header names to lowercase). Reading any other header name, or a header renamed by a proxy along the way, never finds a valid value.

Fix

const signature = req.headers['stripe-signature'];

11

Tolerance set to zero, or a skewed server clock

constructEvent() compares the timestamp Stripe signed into the header against the server clock, inside a tolerance window that defaults to 300 seconds. Stripe's docs warn: "Don't use a tolerance value of 0," it disables the recency check rather than tightening it. A clock drifted past the window fails the same way, even with a correct secret and body.

Fix

Drop any custom tolerance argument to keep the 300-second default, and sync the server clock with NTP.

12

Serverless and edge runtimes: base64 bodies and constructEventAsync

On AWS Lambda behind API Gateway, the body commonly arrives base64-encoded (event.isBase64Encoded: true) and must be decoded first. Cloudflare Workers and other edge runtimes have no Node crypto module, only the async Web Crypto API, so the synchronous constructEvent() throws CryptoProviderOnlySupportsAsyncError, whose message says: "Use await constructEventAsync(...) instead of constructEvent(...)."

Fix

const payload = event.isBase64Encoded
  ? Buffer.from(event.body, 'base64') : event.body;

// on an edge runtime:
const stripeEvent = await stripe.webhooks.constructEventAsync(body, sig, secret);

13

Checklist

  1. Pass the raw request body, the exact bytes Stripe sent, into constructEvent(), before any JSON.parse or re-serialization touches it.
  2. Confirm the whsec_ secret matches the specific endpoint receiving this request. A Dashboard-created endpoint and a running stripe listen each generate a different one, and test and live modes each have their own.
  3. Read the signature from the stripe-signature header exactly, not a differently named or re-cased copy.
  4. Leave tolerance at the 300-second default, and keep the server clock synced with NTP.
  5. Re-check after every deploy. A new body parser, proxy, or changed environment variable can silently break raw-body access again.

14

Check it in 30 seconds

The Stripe Webhook Signature Doctor runs these twelve checks against your framework and your pasted code, entirely in your browser.

Open Stripe Webhook Signature Doctor

15

Sources