Problem Summary
Stripe signs every webhook payload with a HMAC-SHA256 signature computed on the raw request bytes. When your server verifies the signature using stripe.webhooks.constructEvent(), it recomputes the signature from the body you pass it and compares the result against the Stripe-Signature header. If the bytes do not match exactly, verification fails.
The most common cause by a large margin is that a body-parsing middleware has already transformed the raw bytes into a JavaScript object before constructEvent() runs. Even if you then pass JSON.stringify(req.body) back to constructEvent(), the re-serialised string is not byte-for-byte identical to the original, and verification fails.
Symptoms
- constructEvent() throws: Error: No signatures found matching the expected signature for payload
- Webhook endpoint returns 400 to Stripe, which retries the event repeatedly
- Verification works in the Stripe CLI (stripe listen) locally but fails in production
- Verification fails only after adding express.json() globally to the app
- The Stripe-Signature header is present in logs but verification still fails
The exact error from the Stripe SDK: 'No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? If a webhook integration tool is being used, make sure it is not parsing the payload as a string.'
Root Cause
Stripe computes the webhook signature as HMAC-SHA256(timestamp + "." + raw_body_bytes, endpoint_secret). The raw body is the exact byte sequence Stripe sent over the wire. When Express parses the body with express.json(), the Buffer is deserialised to a JavaScript object. Even though the data is identical, JSON.stringify() may produce a different byte sequence different key order, different whitespace, different encoding of special characters so the HMAC no longer matches.
Fix for Express.js
Route order matters. The Stripe webhook route must be declared before any global express.json() middleware, or it must use express.raw() specifically for that route.
Wrong global JSON parser runs before verification
// ❌ Global JSON parser converts raw bytes to object
app.use(express.json());
app.post('/webhooks/stripe', (req, res) => {
const sig = req.headers['stripe-signature'];
// req.body is now a parsed object, not the original bytes
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
// Throws: No signatures found matching the expected signature
});Correct use express.raw() for the Stripe route only
// ✓ Stripe webhook route uses raw body parser for this route only
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
// req.body is now a Buffer exactly what Stripe sent
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error('Webhook verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
// handle payment
break;
}
res.json({ received: true });
}
);
// Other routes use JSON parsing as normal
app.use(express.json());
app.use('/api', apiRoutes);If you must declare express.json() before the Stripe route (for example because of framework structure), skip JSON parsing for the webhook path specifically: app.use((req, res, next) => { if (req.path === '/webhooks/stripe') return next(); express.json()(req, res, next); });
Fix for Next.js App Router
In the App Router, use request.text() to get the raw body string. Never call request.json() on a webhook route.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
// ✓ Raw text not request.json()
const body = await request.text();
const sig = request.headers.get('stripe-signature');
if (!sig) {
return new Response('Missing stripe-signature header', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object as Stripe.PaymentIntent;
// handle payment
break;
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
});
}Fix for Next.js Pages Router
The Pages Router auto-parses the body by default. Disable it for the webhook route by exporting a config object.
import type { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';
import { buffer } from 'micro';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// ✓ Disable automatic body parsing required for Stripe webhooks
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).end('Method Not Allowed');
}
// Read the raw buffer using 'micro'
const buf = await buffer(req);
const sig = req.headers['stripe-signature'];
if (!sig) {
return res.status(400).json({ error: 'Missing stripe-signature header' });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(buf, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err: any) {
return res.status(400).json({ error: err.message });
}
// Process event
res.json({ received: true });
}Other Causes to Check
Wrong endpoint secret
Each Stripe webhook endpoint has its own signing secret it is not your Stripe secret key or publishable key. It starts with whsec_. You find it in the Stripe Dashboard under Developers > Webhooks > click the endpoint > Signing secret. If you have separate endpoints for test mode and live mode, they have different secrets.
echo $STRIPE_WEBHOOK_SECRET | head -c 10 # Should start with: whsec_- Check you copied the webhook signing secret, not the Stripe secret key (sk_live_ or sk_test_)
- Verify the secret in your .env matches the specific endpoint in the Stripe dashboard
- If you have multiple webhook endpoints (one for test, one for live), confirm you are using the correct secret for the environment receiving the event
- If you rotated the secret in the dashboard, update the environment variable in your hosting platform and restart the server
Stripe-Signature timestamp too old
Stripe includes a timestamp in the Stripe-Signature header (t=...) and by default the SDK rejects events where the timestamp is more than 300 seconds (5 minutes) old. This prevents replay attacks. If your server clock is out of sync with NTP, or if you are processing a webhook event from a queue with high latency, verification will fail even with the correct body and secret.
// Increase the tolerance window if processing from a queue
// Default is 300 seconds (5 minutes)
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET,
600 // 10 minutes tolerance
);Verify Your Fix Works
Test using the Stripe CLI before deploying. The CLI forwards real Stripe events to your local server and shows you exactly what Stripe sends and what your server returns.
stripe listen --forward-to localhost:3000/webhooks/stripestripe trigger payment_intent.succeededIf the CLI shows a 200 response, your verification is working. If it shows 400 with the signature error, the raw body problem is not yet fixed.