PayHook

Stripe webhook signature verification failed: every error message

What each Stripe webhook signature error means and how to fix it in Node, Python, PHP, Ruby, Go, Java and .NET: raw body, whsec_ secret, header, clock.

If stripe.webhooks.constructEvent() throws No signatures found matching the expected signature for payload, either the body is not the exact body Stripe sent, or the secret is not the whsec_ of the endpoint that sent the event: stripe listen, every Dashboard endpoint, and test and live mode each have their own. Each of the other messages points to one thing: a missing or mangled header, a clock more than five minutes off, an empty variable, a synchronous call in Deno, Bun or Cloudflare Workers, or a thin event.

This page lists the signature messages of Stripe's seven official SDKs, what causes each and how to fix it. We read them in each SDK's code and ran every failure against stripe-node 23.0.0 and 22.6.2.

Every message, SDK by SDK

MessageSDKWhat it means
No signatures found matching the expected signature for payloadNode, Python, PHP, Ruby, JavaWrong secret, or the body changed
webhook had no valid signatureGoThe same, or a header without v1
The expected signature was not found in the Stripe-Signature header.NETWrong secret, the body changed, or no v1
Webhook payload must be provided as a string or a Buffer…NodeYou passed parsed JSON
Timestamp outside the tolerance zoneNode, PHP, Java; Python and Ruby add the timestamp in bracketsThe signature matches, but its timestamp is more than 5 minutes old (in PHP, also more than 5 minutes ahead)
timestamp wasn't within toleranceGoThe timestamp is more than 5 minutes old; Go checks it before the signature
The webhook cannot be processed because the current timestamp is outside of the allowed tolerance.NETThe signature matches, but its timestamp is more than 5 minutes off, either way
No stripe-signature header value was providedNode; Python with a longer textNo header
webhook has no Stripe-Signature headerGoNo header
Unable to extract timestamp and signatures from headerNode, Python, PHP, Ruby, JavaNo usable timestamp in the header; PHP and Ruby also say it when there is no header
No signatures found with expected schemeNode, Python, PHP, Ruby, JavaA timestamp, but no v1 signature
webhook has invalid Stripe-Signature headerGoA header Go cannot read
The signature header format is unexpected.NETA header part without =
No webhook secret value was provided. It should start with whsec_Node, Python, PHP, Ruby, Java, .NETThe secret is empty
webhook secret must not be emptyGoThe secret is empty
SubtleCryptoProvider cannot be used in a synchronous contextNode in Deno, Bun, Cloudflare WorkersUse constructEventAsync
You passed a thin event notification to a function that expects a webhookNode; the others in similar wordsA thin event: use parseEventNotification
received event with API version …, but stripe-go … expects API version …Go; .NET says the same about Stripe.netThe signature passed; the API versions differ

No signatures found matching the expected signature for payload

The SDK computed the HMAC-SHA256 of the timestamp, a dot and the body with your secret, and none of the v1 signatures in the header matched it. Only two inputs besides the header go into that: the secret and the body.

Check the secret

  • Every endpoint has its own signing secret. In Workbench, open the endpoint under Webhooks and click Reveal secret.
  • stripe listen prints its own secret when it starts. The events it forwards are signed with that one, not with your Dashboard endpoint's.
  • Endpoints in test mode, in a sandbox and in live mode are separate, each with its own secret.
  • Your API key (sk_…, rk_…) is a different secret and never matches.
  • A space or a newline in the variable breaks it too. stripe-node then adds "Note: The provided signing secret contains whitespace" to the message.
  • After you roll a secret, Stripe signs with the old and the new one until the old one expires, at most 24 hours later.

Check the body

Stripe signs the exact body it sends. If something parses the JSON before your code and you pass it on, as an object or serialized again, the signature cannot match. stripe-node's own message asks whether you pass the raw body and, if a tool forwards the request, whether it keeps the JSON formatting and the newline style.

FrameworkHow to get the raw body
Expressexpress.raw({ type: "application/json" }) on the webhook route, and app.use(express.json()) after that route
Next.js, app routerawait request.text()
Next.js, pages routerexport const config = { api: { bodyParser: false } }, then read the request into a Buffer
Flaskrequest.data
Rails, Sinatrarequest.body.read
AWS API Gateway with LambdaA body mapping template that passes rawBody, as in Stripe's guide

If you pass a parsed object, stripe-node says so directly: "Webhook payload must be provided as a string or a Buffer".

Timestamp outside the tolerance zone

Every SDK rejects a signature whose timestamp is more than 5 minutes older than your server's clock. PHP and .NET also reject one more than 5 minutes in the future; the others accept it. Stripe signs every delivery attempt anew, retries included, so a retry is never the cause. What is:

  • Your server's clock is off. Keep it in sync with NTP.
  • The check runs well after the delivery: a queue, or a saved request verified later.
  • A captured request sent again, which is exactly what the check is for.

Do not set the tolerance to 0: Stripe's documentation warns that it turns the check off. Since stripe-node 23.0.0, released on 1 October 2026, passing 0 to constructEvent does turn it off; in 22.x it fell back to 300 seconds. stripe-node 23 also made stripe.webhooks.signature.verifyHeader() check the time by default, which it did not do before.

No stripe-signature header value was provided

The header is missing, or your code read another one. In Node, the keys of req.headers are lowercase, so req.headers["Stripe-Signature"] is always undefined: use req.headers["stripe-signature"], or request.headers.get("stripe-signature") with a standard Request. A request that does not come from Stripe, like a test with curl, a health check or a scanner, has no header at all; Stripe always sends one.

The same situation reads differently elsewhere: Go says "webhook has no Stripe-Signature header", PHP and Ruby say "Unable to extract timestamp and signatures from header", and when the header is null, Java throws a NullPointerException and .NET a NullReferenceException.

When the header is there, "Unable to extract timestamp and signatures from header" means your code passed something else than its value, such as an array or the whole headers object, or something in between changed it. The value is one line: t=…,v1=…, plus a v0=… on test events, which every SDK ignores. "No signatures found with expected scheme" means the header has a timestamp but no v1 signature.

No webhook secret value was provided

The variable is empty where the code runs: set on your machine but not on the server, or set under another name. Go says "webhook secret must not be empty".

SubtleCryptoProvider cannot be used in a synchronous context

stripe-node computes the signature with Web Crypto in Deno (and so in Supabase Edge Functions), in Bun and in Cloudflare Workers, and Web Crypto has no synchronous API. Call await stripe.webhooks.constructEventAsync(body, signature, secret) instead. It works in Node too, so you can use it everywhere.

You passed a thin event notification to a function that expects a webhook

An event destination with thin payloads sends short notifications, "object": "v2.core.event", which constructEvent refuses after checking the signature. Read them with stripe.parseEventNotification(body, signature, secret), as Stripe's documentation shows.

Go and .NET: received event with API version …

Here the signature passed. stripe-go and Stripe.net then compare the event's API version with the one the SDK was built for, by the release name after the date (stripe-go 87.0.0 expects 2026-09-30.endive), and fail when they differ. An event comes in your endpoint's API version, or your account's if the endpoint has none. Create the endpoint with the SDK's version, or turn the check off with IgnoreAPIVersionMismatch: true in Go or throwOnApiVersionMismatch: false in .NET, knowing that some fields may then not deserialize.

Answer with a 2xx, quickly

  • Any 2xx status counts as delivered; a redirect counts as a failure.
  • In live mode Stripe retries for up to three days with exponential backoff; in a sandbox, three times over a few hours.
  • You can resend an event from the Dashboard for 15 days after it was created, or with stripe events resend for 30 days. A manual resend does not stop the automatic retries.
  • The order of events is not guaranteed. Skip event ids you have already handled, and do not order events by created: it is in seconds, and several events share it.

A handler that works in every runtime

constructEventAsync works in Node, Deno, Bun and Cloudflare Workers alike. Log the message, never the secret or the header:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Next.js route handler. Elsewhere, read the body and the header the same way from a standard Request.
export async function POST(request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");
  let event;
  try {
    event = await stripe.webhooks.constructEventAsync(body, signature, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.warn(`Stripe webhook rejected: ${err.message}`);
    return new Response("invalid signature", { status: 400 });
  }
  // Store event.id, answer, then do the slow work.
  return new Response("ok", { status: 200 });
}

In Express, the raw body comes from express.raw() on the webhook route:

app.post("/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await stripe.webhooks.constructEventAsync(req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.warn(`Stripe webhook rejected: ${err.message}`);
    return res.status(400).send("invalid signature");
  }
  res.sendStatus(200);
});

app.use(express.json()); // after the webhook route, never before it

How PayHook reports it

PayHook, the webhook inspector we are building, checks the signature of each Stripe event as soon as it arrives, with the same five-minute window as stripe-node, and its verdict says which case it is: a signature that matches but is too old, a signature that does not match your secret, or a header that is missing or malformed. It never shows the secret. PayHook is in closed beta; the home page has the details.

Sources

Back to the blog