Plainwork

Plainwork / Webhook Spec Atlas / Stripe webhook signature verification failed

Stripe webhook signature verification failed: no signatures found matching the expected signature

The error Webhook signature verification failed. Err: No signatures found matching the expected signature for payload means one of three inputs to constructEvent() is wrong: the raw request body, the Stripe-Signature header, or the endpoint secret. Stripe's own troubleshooting page says exactly this, and lists the checks below. This page follows docs.stripe.com, Resolve signature verification errors, read on 17 October 2026.

The checks, most likely first

  1. Wrong endpoint secret. Stripe calls this the most common cause. The secret for an endpoint forwarded by the Stripe CLI (printed by stripe listen) is different from the secret of a Dashboard-managed endpoint (Reveal secret on the endpoint). Both start with whsec_, so they are easy to mix up, and a test-mode endpoint has a different secret from the live one. Print the value your code uses and compare it with the one you expect.
  2. The body was changed before verification. Stripe requires the exact UTF-8 string it sent. Frameworks that reorder keys, strip whitespace, parse to JSON and re-stringify, or change the encoding break the signature. In Express, the webhook route must be registered before app.use(express.json()), or use express.raw({type: "application/json"}) on that route. Next.js Pages Router needs the body parser disabled. AWS API Gateway needs a body mapping template that passes the raw body.
  3. The header was read wrongly. The header looks like t=1700000000,v1=abc...,v0=def.... Print it and check it has that shape. Some proxies and frameworks lowercase, rename or truncate headers.
  4. Old timestamp. Stripe's libraries reject a signature whose timestamp is outside a tolerance, 5 minutes by default (do not set it to 0, which disables the check). A retried event gets a new timestamp and signature, so replaying a saved request by hand fails unless you re-sign it.

Verify by hand in Node (no library)

Stripe signs the string <t>.<raw body> with HMAC-SHA256, using the whsec_... string as the key, and sends the result hex-encoded as v1. Several v1 values can appear (for example while a secret is being rolled), so accept if any matches.

import crypto from "node:crypto";

export function verifyStripe(rawBody, header, secret, toleranceSec = 300) {
  const parts = String(header || "").split(",").map(p => p.split("="));
  const t = parts.find(p => p[0] === "t")?.[1];
  const sigs = parts.filter(p => p[0] === "v1").map(p => p[1]);
  if (!t || !sigs.length || !secret) return { ok: false, reason: "missing t, v1 or secret" };
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return { ok: false, reason: "timestamp outside tolerance" };
  const want = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const ok = sigs.some(s => {
    const got = Buffer.from(s, "hex");
    return got.length === want.length && crypto.timingSafeEqual(got, want);
  });
  return ok ? { ok: true } : { ok: false, reason: "no v1 matched: wrong secret or modified body" };
}

Pass rawBody as the bytes you received (convert with .toString("utf8") only for the template string). The length check before timingSafeEqual matters because that function throws on unequal lengths.

Which check failed?

Paste your secret, raw body and header into the free signature debugger. It runs in your browser, recomputes the Stripe signature and tells you whether it is the secret form (a key typed without whsec_), a re-serialised body, a trailing newline or an expired timestamp. The general mismatch checklist covers the other senders, and the Shopify page covers its base64 header.

Handling more than Stripe? The Webhook Spec Atlas ($9) puts the header, signed string, encoding, replay window, timeout, retries and dedupe key for 16 senders side by side, each read from the vendor's own docs, with tested verifiers.

More from the catalogue

Other small tools

See everything →