How to verify Stripe webhook signatures in Node.js (and why your raw body breaks it)
You've seen this error before:
Webhook signature verification failed.
No signatures found matching the expected signature for payload.
The payload looks right. The endpoint secret is correct. You've triple-checked the Stripe dashboard. But the signature doesn't match, and your webhook handler returns a 400 on every single request.
Meanwhile, Stripe is retrying. Your customers aren't getting provisioned. Refunds aren't processing. And you have no record of what happened — because the events that failed verification were never persisted. They're gone. You can't replay them, you can't audit them, and Stripe's retry window is finite.
This is probably the most common issue developers hit when integrating Stripe webhooks — and it has nothing to do with Stripe. It's your framework silently destroying the request body before you get a chance to verify it.
Here's exactly what's happening, and how to fix it in Express, Next.js, and Fastify.
Why the signature fails
Stripe signs every webhook payload with HMAC-SHA256. When you call stripe.webhooks.constructEvent(), the Stripe SDK recomputes the signature from the raw bytes of the HTTP body and compares it against the Stripe-Signature header.
The key word is raw bytes. Not a parsed JavaScript object. Not a re-serialized JSON string. The exact sequence of bytes that left Stripe's servers.
Here's what happens in most setups:
- Stripe sends
{"id":"evt_1234","type":"checkout.session.completed",...}as raw bytes - Your framework's body parser (Express's
express.json(), for example) deserializes the body into a JavaScript object - When you pass that object to
constructEvent(), you callJSON.stringify()on it — butJSON.stringify()doesn't guarantee the same byte output as the original payload - The recomputed signature doesn't match → verification fails
The difference can be as subtle as whitespace, key ordering, or Unicode escaping. JSON.parse(JSON.stringify(obj)) is not a round-trip identity on raw bytes. It never was.
The broken version
This is the code that breaks. It's also the version most tutorials show:
// ❌ This breaks signature verification
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// This parses ALL request bodies as JSON — including webhooks
app.use(express.json());
app.post('/webhook', (req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
JSON.stringify(req.body), // ← not the original bytes
sig!,
process.env.STRIPE_WEBHOOK_SECRET!
);
// never reaches here
} catch (err) {
console.error('Signature verification failed');
return res.sendStatus(400);
}
});By the time your route handler runs, req.body is already a JavaScript object. Calling JSON.stringify() produces a new string that may differ from what Stripe sent. The signature check fails every time.
The fix: preserve the raw body
The solution is straightforward: don't let your framework parse the webhook request body. Give Stripe the raw bytes.
Express
Use express.raw() on the webhook route only. Keep express.json() for everything else:
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Parse JSON for all routes except /webhook
app.use((req, res, next) => {
if (req.originalUrl === '/webhook') {
next();
} else {
express.json()(req, res, next);
}
});
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature']!;
try {
const event = stripe.webhooks.constructEvent(
req.body, // Buffer — the original bytes
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case 'checkout.session.completed':
// provision access
break;
case 'invoice.payment_failed':
// handle failed payment
break;
}
res.sendStatus(200);
} catch (err) {
console.error(
'Webhook signature verification failed:',
err instanceof Error ? err.message : err
);
res.sendStatus(400);
}
}
);express.raw() tells Express to skip JSON parsing and deliver the body as a Buffer. That Buffer contains the exact bytes Stripe sent — which is exactly what constructEvent() needs.
Next.js App Router
In Next.js 13+ with the App Router, route handlers use the Web Request API. The body is a ReadableStream — you consume it once with req.text(), not req.json():
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
// Use req.text() to get the raw string. Do NOT call req.json().
// The body stream can only be consumed once — if you call
// req.json() first, req.text() returns an empty string.
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
try {
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case 'checkout.session.completed':
// provision access
break;
case 'invoice.payment_failed':
// handle failed payment
break;
}
return new Response(null, { status: 200 });
} catch (err) {
console.error(
'Webhook signature verification failed:',
err instanceof Error ? err.message : err
);
return new Response('Invalid signature', { status: 400 });
}
}Important: the Request body is a one-shot ReadableStream. If any middleware or earlier code calls req.json(), the stream is already consumed and req.text() returns "". Make sure nothing touches the body before your webhook handler.
Fastify
Fastify parses request bodies by default. Use fastify-raw-body to preserve the original bytes on specific routes:
import Fastify from 'fastify';
import rawBody from 'fastify-raw-body';
import Stripe from 'stripe';
const fastify = Fastify();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Register the plugin — global: false means you opt in per route
await fastify.register(rawBody, { global: false, runFirst: true });
fastify.post(
'/webhook',
{ config: { rawBody: true } },
async (req, reply) => {
const sig = req.headers['stripe-signature'] as string;
try {
const event = stripe.webhooks.constructEvent(
req.rawBody!,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case 'checkout.session.completed':
break;
case 'invoice.payment_failed':
break;
}
return reply.status(200).send();
} catch (err) {
console.error(
'Webhook signature verification failed:',
err instanceof Error ? err.message : err
);
return reply.status(400).send();
}
}
);rawBody: true in the route config tells the plugin to buffer the original bytes into req.rawBody before Fastify's content-type parser runs. The parsed req.body still works for your application logic — you get both.
This works — but it has a blind spot
The code above solves the signature problem. Your webhook endpoint now verifies every payload correctly. But there's a structural issue that raw body handling doesn't fix: what happens after verification succeeds?
Consider this sequence:
- Stripe sends
checkout.session.completed - Your endpoint verifies the signature — valid
- Your handler starts processing — provisioning the user, updating the database
- Your server crashes, the database times out, or the deploy rolls mid-request
- The event is lost. Your handler returned nothing. Stripe will retry, but you have no record of the first attempt, no payload to inspect, and no way to know what state your system was in when it failed.
Signature verification tells you the event is authentic. It doesn't tell you the event was durably received. If your endpoint crashes between "signature valid" and "business logic complete," the event is gone from your side. Stripe's retry log shows "timeout" or "5xx" — but the payload, your response, and the latency are invisible.
This is the gap between "my webhook endpoint works" and "my webhook pipeline is reliable." The code in this post handles the first part. The second part requires a durable layer that:
- Persists the raw body and headers to a database before acknowledging — so even if your service crashes, the event is recoverable
- Preserves the full delivery trace — request payload, response status, latency, retry count — not just Stripe's side of the story
- Lets you replay failed events without reconfiguring Stripe or waiting for the next retry window
This is exactly what we built HookTrace to solve. It sits between Stripe and your endpoint as a proxy: persists every event to Postgres before sending a 204, forwards the raw body and headers to your service, retries with backoff up to 10 attempts, and gives you a full timeline of every delivery attempt. If something fails, you can replay the exact event with one click — no Stripe dashboard needed.
What it costs to skip verification
Without signature verification, anyone who discovers your webhook URL can POST arbitrary JSON to it. That means:
- Fake
checkout.session.completedevents — users get premium access without paying. I've seen this happen at startups that trusted the payload blindly and provisioned accounts on every POST to/webhook. The fix took weeks because they had to reconcile which accounts were real. - Fake refund or dispute events — your system processes a refund that never happened in Stripe, leaving your accounting out of sync.
- Data corruption — injected payloads with unexpected structures can break downstream logic that assumes the data came from Stripe.
The fix is 15 lines of code. The alternative is a security incident you'll spend days cleaning up.
Production checklist
Before you ship your webhook endpoint to production:
- Verify signatures with raw body — use
express.raw(),req.text(), orfastify-raw-bodydepending on your framework - Respond 200 fast, process async — acknowledge the webhook immediately and handle business logic in a background job. Stripe times out at 20 seconds and will retry, which can cause duplicate processing if your handler is slow
- Implement idempotency — store the
event.idand skip duplicates. Stripe guarantees at-least-once delivery, which means you will see the same event more than once - Log
event.typeandevent.idon every request — when something goes wrong at 3am, these two fields are the fastest way to trace what happened - Alert on consecutive failures — if your webhook endpoint fails 5+ times in a row, something is structurally wrong. Don't wait for Stripe to disable the endpoint — get notified and fix it
- Have a durable persistence layer — if your endpoint crashes after verification but before processing, you need the raw event stored somewhere you can replay it from
If you want signature verification, durable persistence, and a full delivery audit trail without building the infrastructure yourself — learn more at katsuralabs.com.
Revenue Recovery Autopilot will detect broken webhooks that cost you money. Join the early access waitlist.