Skip to main content
RapidDev - Software Development Agency
stripe-guide

How to fix Stripe webhook signature error

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.

What you'll learn

  • What causes Stripe webhook signature verification to fail
  • How to use the correct webhook signing secret
  • How to preserve the raw request body for signature verification
  • How to handle clock skew tolerance
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate6 min read15 minutesStripe API v2024-12+, Node.js 18+, Express/Fastify/Next.jsMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

2

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.

typescript
1const express = require('express');
2const app = express();
3
4// CORRECT: raw body for webhook route
5app.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 JSON
12 sig,
13 process.env.STRIPE_WEBHOOK_SECRET
14 );
15 // Handle event...
16 res.json({ received: true });
17 } catch (err) {
18 res.status(400).send(`Webhook Error: ${err.message}`);
19 }
20 }
21);
22
23// JSON parsing for all other routes
24app.use(express.json());

Expected result: The webhook route receives the raw Buffer body, allowing constructEvent to correctly verify the signature.

3

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.

typescript
1// pages/api/webhook.js (Next.js Pages Router)
2import Stripe from 'stripe';
3import { buffer } from 'micro';
4
5export const config = {
6 api: {
7 bodyParser: false, // Disable Next.js body parsing
8 },
9};
10
11const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
12
13export default async function handler(req, res) {
14 const buf = await buffer(req);
15 const sig = req.headers['stripe-signature'];
16
17 try {
18 const event = stripe.webhooks.constructEvent(
19 buf, sig, process.env.STRIPE_WEBHOOK_SECRET
20 );
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.

4

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.

typescript
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 seconds
7);

Expected result: Signature verification accounts for clock differences between Stripe and your server.

5

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.

typescript
1# Start listening (note the whsec_ secret it prints)
2stripe listen --forward-to localhost:3000/webhook
3
4# In another terminal, trigger an event
5stripe trigger payment_intent.succeeded

Expected result: The CLI forwards signed events to your local server. Signature verification passes using the CLI's whsec_ secret.

Complete working example

webhook-verified.js
1require('dotenv').config();
2const express = require('express');
3const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
4
5const app = express();
6const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
7
8// 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'];
13
14 if (!sig) {
15 console.error('Missing stripe-signature header');
16 return res.status(400).send('Missing signature header');
17 }
18
19 if (!endpointSecret) {
20 console.error('STRIPE_WEBHOOK_SECRET not configured');
21 return res.status(500).send('Webhook secret not configured');
22 }
23
24 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 }
31
32 console.log(`Received event: ${event.type} (${event.id})`);
33
34 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 }
44
45 res.json({ received: true });
46 }
47);
48
49// JSON parsing for all other routes
50app.use(express.json());
51
52app.get('/', (req, res) => res.send('Webhook server running'));
53
54const 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.

ChatGPT Prompt

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.

Stripe Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.