How to Handle Stripe Webhooks in Node.js: Verifying Events and Avoiding Duplicate Charges

How to Handle Stripe Webhooks in Node.js: Verifying Events and Avoiding Duplicate Charges

by | Oct 17, 2026 | Uncategorized | 0 comments

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

  1. Use the raw request body for signature verification, never a parsed JSON object.
  2. Verify every event with stripe.webhooks.constructEvent() and your endpoint secret (whsec_...).
  3. Store the event ID (evt_...) with a unique constraint so a replayed event is a no-op.
  4. Return 200 fast (under a few seconds) and do the slow work in a queue or background job.
  5. Test with the Stripe CLI before you touch a production endpoint, including forced retries.
webhook code laptop

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.

webhook code laptop

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.created against a last_event_at column 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.
webhook code laptop

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:

  1. Verify the signature.
  2. Persist the raw event (that is your durable queue entry).
  3. Return 200.
  4. 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

  1. Trigger checkout.session.completed and confirm one order is created.
  2. Run stripe events resend on the same event ID three times.
  3. Assert: still one order row, one email sent, and three rows in your logs saying “duplicate event ignored”.
  4. 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.

webhook code laptop

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.
webhook code laptop

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_events table with processed_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.