Fix: Stripe Webhook Signature Verification Failed
Quick Answer
How to fix Stripe webhook signature verification errors — why Stripe-Signature header validation fails, how to correctly pass the raw request body, and how to debug webhook delivery in the Stripe dashboard.
The Error
Your Stripe webhook endpoint returns a 400 or 500 error with a message like:
No signatures found matching the expected signature for payload.Or when using the Stripe SDK:
StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
at module.exports.constructEvent (stripe/lib/Webhooks.js)Or in Express logs:
Error: Webhook Error: No signatures found matching the expected signature for payload.The Stripe dashboard shows the webhook delivery attempt failed with a non-2xx response, and Stripe retries the event — sometimes triggering duplicate processing.
Why This Happens
Stripe signs webhook payloads using HMAC-SHA256 with your webhook signing secret. The signature is sent in the Stripe-Signature header and computed against the raw, unmodified request body bytes.
Verification fails when:
- The raw body has been parsed before verification — Express’s
express.json()orbody-parsermiddleware parses and re-serializes the JSON body, changing whitespace or key ordering, making the signature invalid. - Wrong signing secret — using the live mode secret in test mode (or vice versa), or using the API secret key instead of the webhook signing secret.
- Multiple body-parser middlewares — one middleware parses the body globally, and the verification step receives the already-parsed object instead of a Buffer.
- Proxy or load balancer modifying the body — some proxies compress or re-encode the request body.
- Clock skew — Stripe’s signature includes a timestamp; if your server clock is off by more than 5 minutes (the default tolerance), verification rejects the event.
- Using the wrong webhook secret — each webhook endpoint (test vs. live, each URL) has its own signing secret. Confirm you are using the secret for the specific endpoint.
Fix 1: Pass the Raw Buffer to constructEvent
The most common cause: express.json() is applied globally before the webhook route, converting the Buffer to a parsed object.
Broken — body already parsed when webhook handler runs:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// Applied globally — parses body for ALL routes including /webhook
app.use(express.json());
app.post('/webhook', (req, res) => {
const sig = req.headers['stripe-signature'];
try {
// req.body is a parsed object, not a raw Buffer — verification fails
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// ...
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
});Fixed — use express.raw() for the webhook route, apply json() for others:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// Webhook route MUST come before express.json() — uses raw body parser
app.post(
'/webhook',
express.raw({ type: 'application/json' }), // Gives req.body as a Buffer
(req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body, // Buffer ✓
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.deleted':
handleSubscriptionCanceled(event.data.object);
break;
}
res.json({ received: true });
} catch (err) {
console.error('Webhook error:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
}
);
// Apply json parser for all other routes AFTER the webhook route
app.use(express.json());
app.use('/api', apiRouter);Why route order matters: Express applies middleware in the order it is registered. The webhook route registers its own
express.raw()middleware before the globalexpress.json()runs. Ifexpress.json()is registered first viaapp.use(), it runs before any route-specific middleware and parses the body into an object.
Fix 2: Verify You Are Using the Correct Webhook Secret
Each webhook endpoint has its own signing secret — it is not your Stripe API secret key.
Where to find the webhook signing secret:
- Go to Stripe Dashboard → Developers → Webhooks.
- Click your webhook endpoint.
- Under “Signing secret”, click “Reveal” to see the
whsec_...prefixed secret.
Test mode vs. live mode: Stripe has separate webhook secrets for test mode and live mode. The signing secret for a test endpoint starts with whsec_test_ in some configurations. Confirm you are in the correct mode (look for “Test mode” in the dashboard header).
For local development — use the Stripe CLI:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/webhookThe CLI outputs a temporary signing secret:
> Ready! You are using Stripe API Version [2023-10-16]. Your webhook signing secret is whsec_abc123... (^C to quit)Use this temporary secret (not the dashboard secret) for local development:
const endpointSecret = process.env.NODE_ENV === 'production'
? process.env.STRIPE_WEBHOOK_SECRET // Dashboard secret
: 'whsec_abc123...'; // CLI temporary secret — set via environment variableFix 3: Fix for Framework-Specific Body Parsing
Next.js API route:
Next.js parses request bodies by default. Disable it for the webhook route:
// pages/api/webhook.js or app/api/webhook/route.js
// Disable automatic body parsing for this route
export const config = {
api: {
bodyParser: false, // Required — let Stripe receive raw body
},
};
// pages/api/webhook.js (Pages Router)
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).end();
}
// Read raw body manually
const rawBody = await new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Handle event...
res.json({ received: true });
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
}Next.js App Router (route.js):
// app/api/webhook/route.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
const body = await request.text(); // Raw text body — not parsed
const sig = request.headers.get('stripe-signature');
try {
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Handle event...
return Response.json({ received: true });
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
}FastAPI (Python):
from fastapi import FastAPI, Request, HTTPException
import stripe
import os
app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
webhook_secret = os.environ["STRIPE_WEBHOOK_SECRET"]
@app.post("/webhook")
async def stripe_webhook(request: Request):
payload = await request.body() # Raw bytes — not parsed
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except stripe.error.SignatureVerificationError as e:
raise HTTPException(status_code=400, detail=str(e))
# Handle the event
if event["type"] == "payment_intent.succeeded":
payment_intent = event["data"]["object"]
handle_payment_success(payment_intent)
return {"status": "success"}Fix 4: Handle Webhook Events Idempotently
Stripe retries failed webhook deliveries — your handler may receive the same event multiple times. Always process events idempotently using the event ID:
// Track processed event IDs to prevent duplicate processing
const processedEvents = new Set(); // Use Redis or a database in production
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Idempotency check — skip if already processed
if (processedEvents.has(event.id)) {
console.log(`Skipping duplicate event: ${event.id}`);
return res.json({ received: true }); // Still return 200 — Stripe will stop retrying
}
try {
await handleEvent(event);
processedEvents.add(event.id);
res.json({ received: true });
} catch (err) {
console.error(`Error processing event ${event.id}:`, err);
// Return 500 to signal Stripe to retry
res.status(500).json({ error: 'Processing failed' });
}
});
// In production — store processed event IDs in a database
async function isEventProcessed(eventId) {
const existing = await db.query(
'SELECT id FROM processed_webhook_events WHERE event_id = $1',
[eventId]
);
return existing.rows.length > 0;
}
async function markEventProcessed(eventId, eventType) {
await db.query(
'INSERT INTO processed_webhook_events (event_id, event_type, processed_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING',
[eventId, eventType]
);
}Fix 5: Return 200 Quickly, Process Asynchronously
Stripe expects a response within 30 seconds. For long-running processing, acknowledge immediately and process in the background:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Respond immediately to Stripe — do not wait for processing
res.json({ received: true });
// Process asynchronously after responding
setImmediate(async () => {
try {
await processStripeEvent(event);
} catch (err) {
console.error(`Failed to process event ${event.id}:`, err);
// Log to an error tracking service — Stripe already got its 200
}
});
});Warning: If you respond with 200 and then fail to process the event, Stripe will not retry — it considers the delivery successful. Either use a job queue with retry logic (BullMQ, Sidekiq, etc.) or respond with a non-2xx code if you cannot guarantee processing.
Fix 6: Debug Webhook Delivery in the Stripe Dashboard
Check failed webhook attempts:
- Stripe Dashboard → Developers → Webhooks → click your endpoint.
- Under “Recent deliveries”, find the failed attempt.
- Click the attempt to see the request headers, payload, and your response body.
- The response body from your endpoint is shown — check it for error details.
Re-send a specific event for testing:
# Using Stripe CLI
stripe events resend evt_1234567890abcdef
# Or from the dashboard: Developers → Events → find event → "Resend to endpoint"Test webhook handling locally without live events:
# Trigger a specific event type via CLI
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failedStill Not Working?
Check for a reverse proxy stripping or modifying headers. If your server is behind nginx, Cloudflare, or a load balancer, confirm the Stripe-Signature header is being forwarded unchanged. Some proxies strip non-standard headers.
Check your server’s clock. Stripe includes a timestamp in the signature. If your server clock is off by more than 5 minutes, verification fails:
# Check server time
date -u
# Sync clock via NTP
timedatectl set-ntp trueDisable tolerance for debugging only:
// Default tolerance is 300 seconds (5 minutes)
// Set to 0 to disable timestamp checking (testing only — never in production)
const event = stripe.webhooks.constructEvent(
payload,
sig,
secret,
0 // tolerance in seconds — 0 disables timestamp check
);Verify the raw body with a debug log:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
console.log('Content-Type:', req.headers['content-type']);
console.log('Body type:', typeof req.body, Buffer.isBuffer(req.body));
console.log('Body length:', req.body?.length);
// Should log: "object", true, and a non-zero number
// If you see "string" or a parsed object, your body parser middleware is running first
});For related API integration issues, see Fix: CORS Access-Control-Allow-Origin Error and Fix: Express CORS Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Express req.body Is undefined
How to fix req.body being undefined in Express — missing body-parser middleware, wrong Content-Type header, middleware order issues, and multipart form data handling.
Fix: Node.js Crashing with UnhandledPromiseRejection (--unhandled-rejections)
How to fix Node.js UnhandledPromiseRejectionWarning and process crashes — why unhandled promise rejections crash Node.js 15+, how to add global handlers, find the source of the rejection, and fix async error handling.
Fix: GraphQL 400 Bad Request Error (Query Syntax and Variable Errors)
How to fix GraphQL 400 Bad Request errors — malformed query syntax, variable type mismatches, missing required fields, schema validation failures, and how to debug GraphQL errors from Apollo and fetch.
Fix: Socket.IO Not Connecting (CORS, Transport, and Namespace Errors)
How to fix Socket.IO connection failures — CORS errors, transport fallback issues, wrong namespace, server not emitting to the right room, and how to debug Socket.IO connections in production.