Fix: Stripe Webhook Signature Verification Failed
Part of: JavaScript & TypeScript Errors
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.
In Production: Incident Lens
How the incident surfaces. Signature failures are a payment-flow incident, and that is the single most expensive class of bug a small team can ship. The Stripe Dashboard delivery log fills with non-2xx responses, your endpoint logs No signatures found matching, and the events that should have moved orders forward sit unprocessed. The user-facing symptom depends on which event you missed: missed payment_intent.succeeded means the order is paid but stuck in “pending payment,” missed customer.subscription.deleted means a churned customer keeps their access, missed invoice.payment_failed means a dunning email never fires. The customer support inbox fills faster than the error rate dashboard updates.
Blast radius. The first hour usually buys you grace because Stripe auto-retries with exponential backoff up to about 3 days, so the queue absorbs short outages. The damage compounds when the bug is silent: the endpoint returns 500 on a body-parser misconfig that ships with a Friday deploy, the on-call notices Monday morning, and you have a 72-hour backlog of events to replay. Revenue impact is bounded by Stripe’s retry window but operational impact (manual reconciliation, support tickets, accounting fixes) scales linearly with the gap.
The monitoring signal that catches it. Stripe Dashboard → Webhooks → endpoint detail page is the source of truth — alert on the “Success rate (last 24h)” dropping below 99% via the Stripe webhook delivery metric (available via the Stripe API or Dashboard alerts). Layer in: application logs filtered on StripeSignatureVerificationError and Webhook Error, synthetic check that triggers a stripe trigger payment_intent.succeeded against the staging endpoint daily, and a worker-lag metric on the event-processing queue if you process asynchronously. A drop in the success-rate metric is more reliable than alerting on application 5xx because Stripe’s view excludes events you happily 200’d but failed to actually process.
Recovery sequence. First, stop accepting more damage: confirm the bug is server-side (your logs show the signature mismatch) and not a Stripe outage (check status.stripe.com). Second, identify the affected window from the Stripe delivery log and use stripe events resend evt_xxx or the Dashboard bulk-resend to replay failed events after the fix lands. Third, fix the bug — usually a body-parser regression from a recent middleware change. Do not rotate the signing secret unless you have evidence it leaked; rotating breaks any in-flight retries that Stripe was about to redeliver. Fourth, after the fix is deployed, replay the affected window in event order to keep downstream state consistent.
Postmortem-style preventive. The durable controls: a dedicated webhook secret per endpoint per environment so test/prod cannot cross-pollute; idempotency table keyed on event.id so replays from Stripe (or your own) cannot double-process; a dead-letter queue with replay tooling so events that fail business logic do not get lost; and a smoke test in CI that posts a known-good signed payload to the webhook route and asserts a 2xx. Pair this with a runbook entry that explicitly tells the next on-call which Stripe delivery log to open and how to bulk-resend.
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, Fix: Express CORS Not Working, Fix: Next.js API Route Not Working, and Fix: SSL Certificate Problem.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: OpenAI API Not Working — RateLimitError, 401, 429, and Connection Issues
How to fix OpenAI API errors — RateLimitError (429), AuthenticationError (401), APIConnectionError, context length exceeded, model not found, and SDK v0-to-v1 migration mistakes.
Fix: Bun Not Working — Node.js Module Incompatible, Native Addon Fails, or bun test Errors
How to fix Bun runtime issues — Node.js API compatibility, native addons (node-gyp), Bun.serve vs Node http, bun test differences from Jest, and common package incompatibilities.
Fix: Node.js Stream Error — Pipe Not Working, Backpressure, or Premature Close
How to fix Node.js stream issues — pipe and pipeline errors, backpressure handling, Transform streams, async iteration, error propagation, and common stream anti-patterns.