Building a Stripe webhook in Node.js looks trivial in a tutorial and then breaks the moment real money flows through it. Two failures show up again and again in production: the signature check fails because a body parser already touched the request, and retried events create duplicate orders, duplicate emails, or duplicate credits in your database.
This guide is the version we wish we had read before shipping our first payment integration. It covers a working Express endpoint, correct raw body handling, signature verification, an idempotency table keyed on the Stripe event ID, fast responses with background processing, and how to test the whole thing locally with the Stripe CLI.
TL;DR: the five rules of a safe Stripe webhook endpoint
- Use the raw request body for signature verification, never a parsed JSON object.
- Verify every event with
stripe.webhooks.constructEvent()and your endpoint secret (whsec_...). - Store the event ID (
evt_...) with a unique constraint so a replayed event is a no-op. - Return 200 fast (under a few seconds) and do the slow work in a queue or background job.
- Test with the Stripe CLI before you touch a production endpoint, including forced retries.

What a Stripe webhook actually is
A webhook endpoint is a public HTTPS URL on your server. When something happens in your Stripe account (a Checkout Session completes, an invoice is paid, a subscription is cancelled), Stripe sends an HTTP POST to that URL with a JSON event object. Your job is to confirm the request really came from Stripe, then act on it exactly once.
Why not just trust the browser redirect after checkout? Because the customer can close the tab, lose connectivity, or never come back. The payment still succeeded. Webhooks are the only reliable source of truth for fulfilment.
What you need before you start
- Node.js 20 or newer (Node 22 LTS is a good default in 2026)
- The official library:
npm install stripe express dotenv - A Stripe account with test mode keys
- The Stripe CLI installed for local forwarding
Step 1: The Express endpoint (and the body parsing trap)
Signature verification works on the exact bytes Stripe sent. If express.json() runs first, req.body becomes a JavaScript object, the original bytes are gone, and verification will fail with “No signatures found matching the expected signature for payload”.
The fix is to mount a raw body parser on the webhook route only, and to declare that route before any global JSON middleware.
// server.js
require('dotenv').config();
const express = require('express');
const Stripe = require('stripe');
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
// 1) Webhook route FIRST, with a raw body parser
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret);
} catch (err) {
console.error('Signature verification failed:', err.message);
// 400 tells Stripe the payload was rejected; it will not help to retry
return res.status(400).send('Webhook Error: ' + err.message);
}
// 2) Acknowledge immediately, process afterwards (see Step 4)
res.status(200).json({ received: true });
try {
await handleEvent(event);
} catch (err) {
console.error('Processing failed for ' + event.id, err);
}
}
);
// 3) Global JSON parser for the rest of the app, AFTER the webhook
app.use(express.json());
app.listen(3000, () => console.log('Listening on :3000'));
Common causes of “signature verification failed”
| Cause | Fix |
|---|---|
express.json() runs before the route |
Move the webhook route above it, or use express.raw() on that path |
| Using the API secret key as the webhook secret | Use the whsec_... value from the endpoint settings or Stripe CLI output |
| Mixing test and live secrets | Each endpoint (test, live, CLI) has its own distinct secret |
| A proxy, WAF or logger rewrites or re-serialises the body | Disable body transformation on that path |
| Server clock drift beyond the tolerance window | Enable NTP on the host |
| Serverless platform auto-parses the request | Read the raw text explicitly (see the framework notes below) |
If you really need a shared JSON parser
You can keep a global parser and stash the raw buffer with the verify hook:
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}));
// then verify with req.rawBody instead of req.body
event = stripe.webhooks.constructEvent(req.rawBody, signature, endpointSecret);
It works, but a dedicated raw route is clearer and harder to break when someone else adds middleware six months later.

Step 2: Get and store the webhook secret
In the Stripe Dashboard, go to Developers > Webhooks (or the Event destinations section, depending on your account layout), add an endpoint such as https://api.yourdomain.com/webhooks/stripe, select the events you care about, and copy the signing secret.
# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
If you run more than one endpoint (for example one for payments and one for Connect accounts), keep one secret per endpoint and pick the right one per route. Never log the secret and never expose it to the browser.
Step 3: Make processing idempotent with event IDs
Stripe retries a webhook delivery whenever it does not receive a 2xx response, and retries continue for a long window with exponential backoff. Network timeouts, a deploy in progress, or a slow database can all trigger a retry after your code already did the work. Without protection, that means two orders, two confirmation emails, or two credits added to a wallet.
Every event carries a unique id like evt_1PxYzAbCdEf. Use it as a primary key.
3.1 A minimal events table (PostgreSQL)
CREATE TABLE stripe_events (
id text PRIMARY KEY,
type text NOT NULL,
payload jsonb,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
3.2 Claim the event before doing any work
async function claimEvent(client, event) {
const result = await client.query(
`INSERT INTO stripe_events (id, type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`,
[event.id, event.type, event]
);
// rowCount === 0 means we have already seen this event ID
return result.rowCount === 1;
}
async function handleEvent(event) {
const client = await pool.connect();
try {
const isNew = await claimEvent(client, event);
if (!isNew) {
console.log('Duplicate event ignored:', event.id);
return;
}
switch (event.type) {
case 'checkout.session.completed':
await fulfilOrder(client, event.data.object);
break;
case 'payment_intent.succeeded':
await markPaymentPaid(client, event.data.object);
break;
case 'payment_intent.payment_failed':
await notifyPaymentFailure(client, event.data.object);
break;
case 'invoice.paid':
await extendSubscription(client, event.data.object);
break;
case 'customer.subscription.deleted':
await revokeAccess(client, event.data.object);
break;
default:
console.log('Unhandled event type:', event.type);
}
await client.query(
'UPDATE stripe_events SET processed_at = now() WHERE id = $1',
[event.id]
);
} finally {
client.release();
}
}
3.3 Add a second layer of protection on your business objects
Event-level deduplication protects you from the same event arriving twice. It does not protect you from two different events describing the same payment (for example checkout.session.completed and payment_intent.succeeded, which both fire for one purchase). So add a database-level guarantee on the entity itself:
-- one order per payment intent, enforced by the database
ALTER TABLE orders
ADD CONSTRAINT orders_payment_intent_unique UNIQUE (stripe_payment_intent_id);
Then write fulfilment as an upsert rather than a blind insert:
async function fulfilOrder(client, session) {
const res = await client.query(
`INSERT INTO orders (stripe_payment_intent_id, stripe_session_id, email, amount_total, currency, status)
VALUES ($1, $2, $3, $4, $5, 'paid')
ON CONFLICT (stripe_payment_intent_id) DO NOTHING
RETURNING id`,
[session.payment_intent, session.id, session.customer_details?.email,
session.amount_total, session.currency]
);
if (res.rowCount === 0) return; // already fulfilled, do not email twice
await sendReceiptEmail(session);
}
Rule of thumb: if an action has a side effect outside your database (email, SMS, shipping label, external API call), it must sit behind a uniqueness check that the database enforces, not behind an if statement in application code.
3.4 Handle out-of-order deliveries
Stripe does not guarantee ordering. A customer.subscription.updated event can arrive before an earlier one. Two defences:
- Compare
event.createdagainst alast_event_atcolumn on the affected record and skip stale events. - For critical state, re-fetch the current object from the API (
stripe.subscriptions.retrieve(id)) instead of trusting the payload snapshot.

Step 4: Respond fast, process in the background
Stripe expects a 2xx response quickly. If your handler sends emails, generates PDFs, or calls a shipping provider inline, you will eventually hit a timeout, Stripe will retry, and you will fight duplicates you could have avoided.
The production pattern is:
- Verify the signature.
- Persist the raw event (that is your durable queue entry).
- Return
200. - Enqueue a job (BullMQ, pg-boss, SQS, Cloud Tasks) that reads the stored event and does the work with retries.
// Inside the route, after verification
await db.query(
`INSERT INTO stripe_events (id, type, payload) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`,
[event.id, event.type, event]
);
res.status(200).json({ received: true });
await queue.add('stripe-event', { eventId: event.id }, {
jobId: event.id, // idempotent job key
attempts: 5,
backoff: { type: 'exponential', delay: 2000 }
});
Status codes matter:
| You return | Stripe behaviour | Use it when |
|---|---|---|
| 200 / 204 | Delivery marked successful | Event accepted, or safely ignored |
| 400 | Marked failed, retried per your retry settings | Invalid signature or malformed payload |
| 500 / timeout | Retried with exponential backoff | Your database or queue is temporarily down |
Never return 500 just because the event type is unknown. Log it and return 200.
Step 5: Test locally with the Stripe CLI
Your laptop has no public URL, and you should not point a live endpoint at a tunnel. The Stripe CLI forwards real test-mode events to localhost.
# 1. Authenticate once
stripe login
# 2. Forward events to your local route
stripe listen --forward-to localhost:3000/webhooks/stripe
# Optional: only the events you care about
stripe listen --events checkout.session.completed,payment_intent.succeeded \
--forward-to localhost:3000/webhooks/stripe
The CLI prints a signing secret that starts with whsec_. Put that value in your local .env; it is different from the Dashboard endpoint secret.
# 3. In a second terminal, fire test events
stripe trigger checkout.session.completed
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
stripe trigger invoice.paid
# 4. Replay a specific event to prove idempotency works
stripe events resend evt_1PxYzAbCdEf
The duplicate test you should actually run
- Trigger
checkout.session.completedand confirm one order is created. - Run
stripe events resendon the same event ID three times. - Assert: still one order row, one email sent, and three rows in your logs saying “duplicate event ignored”.
- Temporarily make your handler throw after the insert, resend the event, and confirm the retry does not double-charge or double-fulfil.
If that passes, your endpoint is in much better shape than most.

Which events should you subscribe to?
Subscribe narrowly. Every extra event type is extra traffic and extra code paths to reason about.
| Event | Typical action |
|---|---|
checkout.session.completed |
Create the order, grant access, send the receipt |
checkout.session.async_payment_succeeded |
Fulfil delayed methods such as bank debits |
payment_intent.succeeded |
Mark the payment settled (custom payment flows) |
payment_intent.payment_failed |
Notify the customer, keep the cart alive |
invoice.paid / invoice.payment_failed |
Extend or suspend a subscription period |
customer.subscription.updated / .deleted |
Sync plan, seats, cancellation state |
charge.refunded |
Revoke access, update accounting |
charge.dispute.created |
Alert support, freeze fulfilment |
Framework notes: raw bodies outside plain Express
Next.js App Router
// app/api/webhooks/stripe/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 string, not request.json()
const signature = request.headers.get('stripe-signature');
let event;
try {
event = await stripe.webhooks.constructEventAsync(
body, signature, process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
await handleEvent(event);
return Response.json({ received: true });
}
Use constructEventAsync on runtimes that only expose the Web Crypto API (edge runtimes, Cloudflare Workers, Deno).
Fastify
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => { done(null, body); }
);
Scope that parser to the webhook route only, otherwise the rest of your API loses JSON parsing. Originally covered on https://github.com.
Serverless and API gateways
- Check whether the platform base64-encodes the body; decode to a Buffer before verifying.
- Disable automatic body parsing for the webhook path where the option exists.
- Keep cold starts in mind: a 10 second cold start plus slow processing equals retries.

Security and operations checklist
- HTTPS only, with a valid certificate. No self-signed certs on live endpoints.
- Never trust amounts from the client. Read
amount_total,currency, and metadata from the verified event. - Do not expose the webhook route to authentication middleware that would reject Stripe (it sends no session cookie or bearer token).
- Keep one secret per endpoint and rotate it through the Dashboard when it leaks; both secrets stay valid during the rollover window.
- Monitor failures. Alert on endpoint error rates, and keep a small admin view over your
stripe_eventstable withprocessed_at IS NULL. - Pin the API version used by your library and test before upgrading, so payload shapes do not change under you.
- Write metadata at creation time (order ID, user ID) on the PaymentIntent or Checkout Session, so the webhook can link the event to your own records without guessing.
FAQ
Why does my Stripe webhook signature verification keep failing in Node.js?
In almost every case a middleware parsed the body before verification. Mount express.raw({ type: 'application/json' }) on the webhook route and declare it before express.json(). Also confirm you are using the whsec_ endpoint secret, not your API key, and that the secret matches the environment (test, live, or Stripe CLI).
Where do I find the Stripe webhook secret key?
For a hosted endpoint, open the endpoint in the Stripe Dashboard under Developers and reveal the signing secret. For local development, the stripe listen command prints its own secret when it starts. They are different values, so keep them in separate environment files. For the wider picture, see Trigger a task from Stripe webhook events.
How often does Stripe retry a failed webhook?
Stripe retries with exponential backoff over a multi-day window in live mode (shorter in test mode) until it gets a 2xx or the window closes. This is exactly why idempotency matters: your handler will be called more than once for the same event sooner or later.
Do I need a queue like BullMQ for Stripe webhooks?
Not for a simple flow that writes one row and returns. Add one as soon as your handler touches email providers, PDF generation, third-party APIs, or anything that can take more than a second or fail independently. Persist the event first, then let the worker retry with your own backoff.
Can I use the same endpoint for test and live mode?
Technically yes, but it is a bad idea because the secrets differ and test data would flow into production logic. Use separate endpoints, separate secrets, and ideally separate deployments.
Should I still verify the payment on the success page?
You can read the session to show a confirmation, but never fulfil from the browser redirect. Fulfil from the verified webhook only. That is the one path that runs even when the customer closes the tab. A step-by-step version exists if you prefer that format.
How do I know an event is not a replay attack?
Signature verification includes a timestamp and rejects payloads outside the default tolerance window, which blocks replays of captured requests. Your event ID table handles legitimate duplicates from Stripe’s own retries.
Wrapping up
A robust Stripe webhook in Node.js is basically four defensive habits: raw body in, signature verified, event ID stored under a unique constraint, and slow work moved off the request. Add the resend test to your QA checklist and you remove the entire class of duplicate order bugs before a customer ever reports one.
Need a second pair of eyes on your payment flow, or a webhook layer that survives retries, deploys, and traffic spikes? Get in touch with the team at Box Software and we will review your integration.
