DEV Community

ARLing
ARLing

Posted on Originally published at arling.sk

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, req.headers['stripe-signature'], secret
  );
});
Enter fullscreen mode Exit fullscreen mode
02
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// wrong: constructEvent(JSON.stringify(JSON.parse(raw)), sig, secret)
const event = stripe.webhooks.constructEvent(raw, sig, secret); // keep the original bytes
Enter fullscreen mode Exit fullscreen mode
03
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
app.post('/webhook', express.raw({ type: 'application/json' }), webhookHandler);
app.use(express.json()); // every other route, mounted after
Enter fullscreen mode Exit fullscreen mode
04
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
  );
}
Enter fullscreen mode Exit fullscreen mode
05
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
const body = await req.text();
Enter fullscreen mode Exit fullscreen mode
06
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode
07
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
const signature = req.headers['stripe-signature'];
Enter fullscreen mode Exit fullscreen mode
11
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
const payload = event.isBase64Encoded
  ? Buffer.from(event.body, 'base64') : event.body;

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

Checklist

  - Pass the raw request body, the exact bytes Stripe sent, into constructEvent(), before any JSON.parse or re-serialization touches it.

  - 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.

  - Read the signature from the stripe-signature header exactly, not a differently named or re-cased copy.

  - Leave tolerance at the 300-second default, and keep the server clock synced with NTP.

  - Re-check after every deploy. A new body parser, proxy, or changed environment variable can silently break raw-body access again.





14
Enter fullscreen mode Exit fullscreen mode

Check it in 30 seconds

The [Stripe Webhook Signature Doctor](https://arling.sk/stripe-webhook-doctor/) runs these twelve checks against your framework and your pasted code, entirely in your browser.

[Open Stripe Webhook Signature Doctor](https://arling.sk/stripe-webhook-doctor/)




15
Enter fullscreen mode Exit fullscreen mode

Sources

  - Stripe docs: Receive Stripe events in your webhook endpoint, verifying signatures

  - Stripe docs: Check webhook signatures, troubleshooting

  - Stripe docs: Stripe CLI overview

  - Next.js docs: Route Handlers (route.ts)

  - Express docs: body-parser middleware

  - stripe-node source: Webhooks.ts (default tolerance, constructEventAsync)

  - stripe-node README: raw body requirement
Enter fullscreen mode Exit fullscreen mode

Originally published at arling.sk. Written by Andrej Lauko with AI assistance; every rule links to the official documentation it comes from.

Top comments (0)