Stripe: “No signatures found matching the expected signature for payload”
Stripe signs every webhook delivery. Your handler recomputes the signature and compares. This error means none of the signatures in the Stripe-Signature header matched what your code computed. Most of the time, the body you verify is not the body Stripe sent, or the secret is not the one of this endpoint.
Open the Stripe signature validator →
What Stripe actually signs
The Stripe-Signature header looks like t=1758790800,v1=5257a869…. Stripe computes an HMAC-SHA256 of the string {t}.{raw request body} with your endpoint's signing secret (whsec_…) and puts the hex result in v1.
constructEvent(payload, header, secret) redoes that computation on the payload you pass. One changed byte in the body (a space, a newline, a key order, a unicode escape) gives a completely different HMAC, and the check fails.
Cause 1: the body was parsed before verification
A JSON body parser reads the request, builds an object, and your code then passes JSON.stringify(object) or the object itself. The re-serialised string is not byte-identical to what Stripe sent: Stripe sends indented JSON, JSON.stringify does not. Keep the raw bytes for this route.
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.get('stripe-signature'), process.env.STRIPE_WEBHOOK_SECRET);
res.sendStatus(200);
});
// Register this route BEFORE app.use(express.json())export async function POST(req: Request) {
const body = await req.text(); // not req.json()
const event = stripe.webhooks.constructEvent(body, req.headers.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!);
return new Response('ok');
}const app = await NestFactory.create(AppModule, { rawBody: true });
// in the controller: @Req() req: RawBodyRequest<Request> → req.rawBodypayload = request.body # bytes, untouched event = stripe.Webhook.construct_event(payload, request.headers['Stripe-Signature'], endpoint_secret)
payload = request.get_data() event = stripe.Webhook.construct_event(payload, request.headers['Stripe-Signature'], endpoint_secret)
$payload = $request->getContent(); // not json_encode($request->all())
$event = \Stripe\Webhook::constructEvent($payload, $request->header('Stripe-Signature'), $secret);Cause 2: the secret belongs to another endpoint
Each webhook endpoint has its own whsec_ secret, and test mode and live mode endpoints have different ones. The Stripe CLI (stripe listen) prints yet another secret for the deliveries it forwards. If you switched from the CLI to a dashboard endpoint, or from test to live, update the environment variable.
Copy-paste also adds invisible characters: a trailing newline in a .env file is enough. Trim the value.
Cause 3: something between Stripe and your code rewrites the body
A proxy, an API gateway or a serverless adapter can decode, re-encode or re-indent the body before your function sees it. Compare what arrived with what your code verifies: capture a real delivery on a Webhook Toolkit URL (exact bytes, byte count shown), then log the length of the string you pass to constructEvent. Different lengths settle it.
A timestamp problem gives a different message (Timestamp outside the tolerance zone): the default tolerance is 300 seconds, so check the server clock or a replay of an old event.
Check it in one minute
Create a capture URL, add it as an endpoint in the Stripe dashboard (Developers → Webhooks) or forward with npx webhook-toolkit listen --forward localhost:3000/webhooks, trigger an event, then paste the raw body, the header and your secret in the Stripe signature validator. It tells you whether the secret, the body or the timestamp is wrong.
Frequently asked questions
Can I verify the signature on req.body after express.json()?
No. express.json() replaces req.body with an object and the original bytes are gone. Use express.raw() on the webhook route, or keep a copy of the raw buffer with the verify option of express.json().
Why does it work with the Stripe CLI and fail in production?
The CLI signs forwarded events with its own secret. Your deployed endpoint has a different whsec_ secret, shown on the endpoint's page in the dashboard.
Can I skip the verification in development?
You can, but the bug then shows up in production. Verify from the start, with the secret of the endpoint that actually delivers.