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
| Message | SDK | What it means |
|---|---|---|
| No signatures found matching the expected signature for payload | Node, Python, PHP, Ruby, Java | Wrong secret, or the body changed |
| webhook had no valid signature | Go | The same, or a header without v1 |
| The expected signature was not found in the Stripe-Signature header | .NET | Wrong secret, the body changed, or no v1 |
| Webhook payload must be provided as a string or a Buffer… | Node | You passed parsed JSON |
| Timestamp outside the tolerance zone | Node, PHP, Java; Python and Ruby add the timestamp in brackets | The signature matches, but its timestamp is more than 5 minutes old (in PHP, also more than 5 minutes ahead) |
| timestamp wasn't within tolerance | Go | The 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 | .NET | The signature matches, but its timestamp is more than 5 minutes off, either way |
| No stripe-signature header value was provided | Node; Python with a longer text | No header |
| webhook has no Stripe-Signature header | Go | No header |
| Unable to extract timestamp and signatures from header | Node, Python, PHP, Ruby, Java | No usable timestamp in the header; PHP and Ruby also say it when there is no header |
| No signatures found with expected scheme | Node, Python, PHP, Ruby, Java | A timestamp, but no v1 signature |
| webhook has invalid Stripe-Signature header | Go | A header Go cannot read |
| The signature header format is unexpected | .NET | A header part without = |
No webhook secret value was provided. It should start with whsec_ | Node, Python, PHP, Ruby, Java, .NET | The secret is empty |
| webhook secret must not be empty | Go | The secret is empty |
| SubtleCryptoProvider cannot be used in a synchronous context | Node in Deno, Bun, Cloudflare Workers | Use constructEventAsync |
| You passed a thin event notification to a function that expects a webhook | Node; the others in similar words | A thin event: use parseEventNotification |
| received event with API version …, but stripe-go … expects API version … | Go; .NET says the same about Stripe.net | The 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 listenprints 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.
| Framework | How to get the raw body |
|---|---|
| Express | express.raw({ type: "application/json" }) on the webhook route, and app.use(express.json()) after that route |
| Next.js, app router | await request.text() |
| Next.js, pages router | export const config = { api: { bodyParser: false } }, then read the request into a Buffer |
| Flask | request.data |
| Rails, Sinatra | request.body.read |
| AWS API Gateway with Lambda | A 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 resendfor 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
- Stripe documentation, Receive Stripe events in your webhook endpoint: retries, resending, event order, rolling secrets and replay protection; Manage webhook endpoints: signature errors and raw bodies; and the API reference for creating a webhook endpoint: its API version. Checked on 9 October 2026.
- The webhook code of Stripe's SDKs at their latest releases, read on 9 October 2026: stripe-node 23.0.0, stripe-python 16.0.0, stripe-php 22.0.0, stripe-ruby 20.0.0, stripe-go 87.0.0 (webhooks.go, webhook/client.go), stripe-java 34.0.0 and Stripe.net 53.0.0.
- The messages and the change of the tolerance in stripe-node 23: PayHook's own run of stripe-node 23.0.0 and 22.6.2, 9 October 2026.