Stripe webhook signature verification fails when the signing secret is wrong, the request body has been parsed or modified before verification, or there's significant clock skew between Stripe and your server. Fix it by using the correct webhook secret (whsec_), ensuring the raw request body reaches constructEvent, and keeping your server clock synchronized.
Why Stripe Webhook Signature Verification Fails
Stripe signs every webhook event with an HMAC-SHA256 signature using your endpoint's signing secret. When you call stripe.webhooks.constructEvent(), it recomputes the signature from the raw body and compares it. If the signing secret is wrong, the body has been modified (e.g., parsed to JSON then re-stringified), or the timestamp is too far off, the verification fails. This is a critical security feature — never skip it.
Prerequisites
- A Stripe account with a registered webhook endpoint
- Node.js 18+ with Express or another web framework
- Your webhook signing secret from the Stripe Dashboard or CLI
Step-by-step guide
Use the correct webhook signing secret
Use the correct webhook signing secret
Each webhook endpoint has its own unique signing secret (starts with whsec_). Find it in Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret. If you're testing locally with the Stripe CLI, use the whsec_ secret printed by 'stripe listen'.
Expected result: You have the correct whsec_ signing secret for your specific endpoint and environment.
Ensure the raw request body reaches constructEvent
Ensure the raw request body reaches constructEvent
The most common cause of signature failure is body parsing. If Express parses the body to JSON before your webhook handler runs, the re-stringified body won't match Stripe's signature. Use express.raw() for the webhook route.
1const express = require('express');2const app = express();34// CORRECT: raw body for webhook route5app.post('/webhook',6 express.raw({ type: 'application/json' }),7 (req, res) => {8 const sig = req.headers['stripe-signature'];9 try {10 const event = stripe.webhooks.constructEvent(11 req.body, // This is a Buffer, not parsed JSON12 sig,13 process.env.STRIPE_WEBHOOK_SECRET14 );15 // Handle event...16 res.json({ received: true });17 } catch (err) {18 res.status(400).send(`Webhook Error: ${err.message}`);19 }20 }21);2223// JSON parsing for all other routes24app.use(express.json());Expected result: The webhook route receives the raw Buffer body, allowing constructEvent to correctly verify the signature.
Fix body parsing in Next.js API routes
Fix body parsing in Next.js API routes
Next.js automatically parses request bodies. You must disable body parsing for the webhook route and read the raw body manually.
1// pages/api/webhook.js (Next.js Pages Router)2import Stripe from 'stripe';3import { buffer } from 'micro';45export const config = {6 api: {7 bodyParser: false, // Disable Next.js body parsing8 },9};1011const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);1213export default async function handler(req, res) {14 const buf = await buffer(req);15 const sig = req.headers['stripe-signature'];1617 try {18 const event = stripe.webhooks.constructEvent(19 buf, sig, process.env.STRIPE_WEBHOOK_SECRET20 );21 // Handle event...22 res.json({ received: true });23 } catch (err) {24 res.status(400).send(`Webhook Error: ${err.message}`);25 }26}Expected result: Next.js API route receives the raw body buffer, and signature verification succeeds.
Handle clock skew
Handle clock skew
Stripe includes a timestamp in the signature. By default, constructEvent rejects events with timestamps more than 300 seconds (5 minutes) off from your server time. If your server clock is out of sync, either fix the clock or increase the tolerance.
1// Increase tolerance to 600 seconds (only if your server clock drifts)2const event = stripe.webhooks.constructEvent(3 req.body,4 sig,5 endpointSecret,6 600 // tolerance in seconds7);Expected result: Signature verification accounts for clock differences between Stripe and your server.
Test with the Stripe CLI
Test with the Stripe CLI
Use the Stripe CLI to send test webhook events to your local endpoint. The CLI provides its own signing secret that you must use during local testing.
1# Start listening (note the whsec_ secret it prints)2stripe listen --forward-to localhost:3000/webhook34# In another terminal, trigger an event5stripe trigger payment_intent.succeededExpected result: The CLI forwards signed events to your local server. Signature verification passes using the CLI's whsec_ secret.
Complete working example
1require('dotenv').config();2const express = require('express');3const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);45const app = express();6const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;78// Webhook route with raw body — MUST come before express.json()9app.post('/webhook',10 express.raw({ type: 'application/json' }),11 (req, res) => {12 const sig = req.headers['stripe-signature'];1314 if (!sig) {15 console.error('Missing stripe-signature header');16 return res.status(400).send('Missing signature header');17 }1819 if (!endpointSecret) {20 console.error('STRIPE_WEBHOOK_SECRET not configured');21 return res.status(500).send('Webhook secret not configured');22 }2324 let event;25 try {26 event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);27 } catch (err) {28 console.error('Signature verification failed:', err.message);29 return res.status(400).send(`Webhook Error: ${err.message}`);30 }3132 console.log(`Received event: ${event.type} (${event.id})`);3334 switch (event.type) {35 case 'payment_intent.succeeded':36 console.log('Payment succeeded:', event.data.object.id);37 break;38 case 'payment_intent.payment_failed':39 console.log('Payment failed:', event.data.object.id);40 break;41 default:42 console.log(`Unhandled event type: ${event.type}`);43 }4445 res.json({ received: true });46 }47);4849// JSON parsing for all other routes50app.use(express.json());5152app.get('/', (req, res) => res.send('Webhook server running'));5354const PORT = process.env.PORT || 3000;55app.listen(PORT, () => console.log(`Server on port ${PORT}`));Common mistakes when fixing Stripe webhook signature error
Why it's a problem: Using the Dashboard signing secret with the Stripe CLI (or vice versa)
How to avoid: The CLI prints its own whsec_ secret when you run 'stripe listen'. Use that for local testing. Use the Dashboard secret for production.
Why it's a problem: Placing express.json() middleware before the webhook route
How to avoid: Put the webhook route with express.raw() BEFORE app.use(express.json()). Otherwise Express parses the body to JSON, destroying the raw bytes needed for verification.
Why it's a problem: Re-stringifying the parsed body for constructEvent
How to avoid: JSON.stringify(parsedBody) does not produce the exact same bytes as the original raw body. Whitespace and key ordering may differ. Always use the original raw body.
Why it's a problem: Copying the whsec_ secret with leading/trailing spaces
How to avoid: Trim your webhook secret. Even a single space character causes signature verification to fail.
Best practices
- Always verify webhook signatures — never process unverified events
- Use express.raw() specifically for your webhook route to preserve the raw body
- Store the webhook signing secret in environment variables, not in code
- Use separate signing secrets for local (CLI) and production (Dashboard) environments
- Keep your server clock synchronized with NTP to avoid clock skew issues
- Log signature failures for debugging but never log the signing secret itself
- Return 200 quickly after verification — process events asynchronously if needed
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a Node.js Express webhook endpoint that verifies Stripe webhook signatures using stripe.webhooks.constructEvent. Use express.raw() for the route. Handle missing signature headers, missing webhook secret, and verification failures with proper error responses.
Build a Stripe webhook handler in Node.js Express that verifies signatures correctly. Use raw body parsing, check for missing headers and secrets, and include error handling for signature verification failures.
Frequently asked questions
Where do I find my Stripe webhook signing secret?
In the Stripe Dashboard, go to Developers → Webhooks → click your endpoint → Signing secret → click to reveal. It starts with whsec_. For local testing with the CLI, the secret is printed when you run 'stripe listen'.
Why does signature verification fail even with the correct secret?
The most common cause is body parsing. If your framework parses the body to JSON before constructEvent runs, the re-stringified body won't match. You must pass the raw, unparsed body bytes to constructEvent.
Can I skip webhook signature verification?
You should never skip it in production. Without verification, anyone can send fake events to your endpoint. In development, you can temporarily disable it for debugging, but always re-enable it before deploying.
What is clock skew in Stripe webhooks?
Stripe includes a timestamp in the webhook signature. If your server clock is off by more than 5 minutes, verification fails. Use NTP to keep your server clock accurate, or increase the tolerance parameter in constructEvent.
Does each webhook endpoint have a different signing secret?
Yes. Each endpoint registered in the Stripe Dashboard has its own unique whsec_ signing secret. If you have multiple endpoints, make sure each uses its own secret.
How do I debug webhook signature failures?
Log the type of req.body (should be Buffer, not object), verify the whsec_ secret matches your endpoint, check that no middleware modifies the body before your handler, and confirm your server clock is accurate.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation