Stripe webhooks that never double-charge: idempotency in Node.js

Stripe can deliver the same webhook twice and in any order. How to verify, de-duplicate and process events safely with Express and PostgreSQL.

Payment code has one job above all others: do the right thing exactly once. Stripe's webhooks make that harder than it looks, because events can arrive twice, late or out of order. None of that is a bug. It's how reliable delivery works, and your handler has to expect it.

I worked on the Stripe flow behind Flaps10's memberships and block-hour purchases. These are the rules I follow for any webhook that moves money.

TL;DR#

  • Stripe delivers webhooks at least once, so the same event can arrive twice, late, or out of order.
  • Verify the Stripe-Signature header against the raw request body.
  • Store each event ID under a unique key, in the same database transaction as its effects, so a duplicate does nothing.
  • Answer with a 2xx quickly and move slow work to a queue.
  • Send an idempotency key built from your own record IDs on every request that creates something in Stripe.

Why the same event arrives twice#

Stripe delivers webhooks at least once. If your endpoint times out, returns an error, or its response gets lost on the way back, Stripe tries again; in live mode it keeps retrying for up to three days (Stripe's webhook docs describe the schedule). A slow handler that finishes its work but answers too late will receive the same event again.

So the rule is simple: assume every event can arrive more than once, and make handling it a second time do nothing.

Stripe sends evt_1, the endpoint records it and its effects, the 200 is lost, Stripe retries, the insert finds evt_1 already recorded and the endpoint answers 200 without doing anything twice
A lost response is the most common way to get a duplicate. The event ID turns the retry into a no-op.

A few details from Stripe's docs that matter in practice:

  • Sandboxes retry less. Live mode retries for up to three days; a sandbox retries three times over a few hours. A handler that "never sees duplicates" in testing will see them in production.
  • A retry gets a new signature and timestamp. Each delivery attempt is signed again, so you can't detect a duplicate by its signature. Only the event ID tells you.
  • Resending by hand doesn't stop the automatic retries. Clicking Resend in the Dashboard (up to 15 days after the event) or running stripe events resend (up to 30 days) delivers it again, but Stripe's own retry schedule carries on.
  • Stripe can send two events for one change. For that case, Stripe suggests de-duplicating on the object ID in data.object together with event.type.

Verify the signature, on the raw body#

Every webhook carries a Stripe-Signature header. It's computed over the exact bytes Stripe sent, so this route needs the raw request body rather than JSON parsed by middleware:

import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

// The raw body for this route only, registered before express.json().
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.get('stripe-signature'),
        process.env.STRIPE_WEBHOOK_SECRET,
      );
    } catch (err) {
      return res.status(400).send(`Signature check failed: ${err.message}`);
    }

    try {
      await handle(event);
      return res.json({ received: true });
    } catch (err) {
      console.error(err);
      return res.status(500).end(); // Stripe will retry
    }
  },
);

app.use(express.json()); // every other route

When signature verification fails#

Almost every failed verification is one of five problems. stripe-node's error messages say which, if you read past the first line:

Error message (stripe-node) What it means Fix
Webhook payload must be provided as a string or a Buffer … representing the _raw_ request body. Payload was provided as a parsed JavaScript object instead. A JSON parser ran before your route Register express.raw() on the webhook route before express.json()
No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? The body was changed in transit, or the secret is wrong Check for middleware or a proxy rewriting the body, then check the secret
No webhook secret value was provided. It should start with whsec_ The environment variable is empty Load the secret before the route runs
Timestamp outside the tolerance zone The signature is older than 5 minutes Sync the server clock with NTP; don't replay old payloads
…The provided signing secret contains whitespace (appended to the error) A newline or space crept into the secret Trim the value where you store it

The secret mix-up deserves its own line, because it's the one that wastes an afternoon. stripe listen prints its own whsec_ secret, different from the one on your Dashboard endpoint, and test mode and live mode have different secrets too. A handler that works locally and fails in production is almost always using the CLI's secret in production, or the other way round.

The raw body in NestJS and Next.js#

Express needs express.raw(), as above. Other frameworks parse JSON for you, so you have to ask for the raw bytes:

// NestJS: main.ts
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });

// stripe-webhook.controller.ts
@Post('webhooks/stripe')
handle(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') signature: string) {
  const event = this.stripe.webhooks.constructEvent(req.rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET);
  // ...
}

Nest keeps parsing JSON for every other route; rawBody: true only adds the untouched bytes as req.rawBody (Nest's raw body docs).

// Next.js, Pages Router: pages/api/webhooks/stripe.js
export const config = { api: { bodyParser: false } };

export default async function handler(req, res) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const event = stripe.webhooks.constructEvent(Buffer.concat(chunks), req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
  // ...
}

In the App Router, read the body with await request.text() in the route handler and pass that string.

Record each event exactly once#

Keep the IDs of the events you've handled in a table where an ID can only appear once:

CREATE TABLE stripe_events (
  id          text PRIMARY KEY,  -- evt_…
  type        text NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);

Then record the event and do the work in a single transaction:

async function handle(event) {
  const db = await pool.connect();
  try {
    await db.query('BEGIN');
    const { rowCount } = await db.query(
      'INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING',
      [event.id, event.type],
    );
    if (rowCount === 0) {
      await db.query('ROLLBACK');
      return; // handled already
    }

    await apply(event, db); // the real work, inside the same transaction
    await db.query('COMMIT');
  } catch (err) {
    await db.query('ROLLBACK');
    throw err;
  } finally {
    db.release();
  }
}

The event ID and its effects commit together. A crash halfway through rolls both back, and the retry starts from scratch. If two deliveries of one event race each other, the second insert waits for the first transaction, then finds the ID already there and does nothing.

Answer fast, work later#

Stripe expects a 2xx quickly. Sending emails, rendering invoices or calling other APIs inside the handler is how you end up with timeouts, and therefore duplicates. Record the event, put the slow work on a queue (a jobs table, BullMQ on Redis, RabbitMQ), and respond. The job carries the event ID, so the worker can skip work it has already done.

A slow or failing endpoint costs more than duplicates, and Stripe's docs spell out two consequences people don't expect:

  • Checkout waits for you. When you set a success_url, Checkout waits up to 10 seconds for your server to answer checkout.session.completed before redirecting the customer (fulfillment docs). A slow handler is a slow checkout.
  • Billing waits for you. If Stripe can't get a successful response to invoice.created, it delays finalising subscription invoices with automatic collection for up to 72 hours (subscription webhooks). A broken webhook endpoint quietly postpones your revenue.

Which events to listen for#

Listen only to the events you act on; Stripe recommends it, and it keeps the handler small. For the two most common things a SaaS sells:

You sell Grant access on Also handle Watch out for
A one-time purchase through Checkout (a package of hours, a lifetime plan) checkout.session.completed when payment_status isn't unpaid checkout.session.async_payment_succeeded and …_failed, for bank debits and other delayed methods The session completes before a delayed payment succeeds; check payment_status
A subscription (a monthly or yearly membership) invoice.paid, after confirming the subscription's status is active customer.subscription.updated and …deleted, invoice.payment_failed Revoke access when the status becomes canceled or unpaid

Both flows also want charge.refunded and charge.dispute.created once real money moves. Stripe's own fulfillment guide has the same warning as this post: your fulfilment code "might be called multiple times, possibly concurrently, for the same Checkout Session."

Event IDs and idempotency keys are different tools#

The two get mixed up because both are about doing things once:

Event ID (evt_…) Idempotency key
Direction Stripe → you You → Stripe
Who creates it Stripe You, from your own record (order-42-payment)
Where it's stored Your stripe_events table, with a unique constraint Stripe, for at least 24 hours
What it prevents Applying one event twice Creating one charge, refund or customer twice

A payment flow needs both: idempotency keys on every write you send, and an event table for everything Stripe sends back.

Don't trust the order#

invoice.paid can arrive before customer.subscription.updated. Rather than replaying each payload as one step in a sequence, treat the event as a signal that something changed, and read the current state from Stripe:

async function apply(event, db) {
  if (!event.type.startsWith('customer.subscription.')) return;

  const subscription = await stripe.subscriptions.retrieve(
    event.data.object.id,
  );
  await db.query(
    'UPDATE accounts SET plan = $1, subscription_status = $2 WHERE stripe_customer_id = $3',
    [
      subscription.items.data[0]?.price.lookup_key,
      subscription.status,
      subscription.customer,
    ],
  );
}

However the events arrive, the last one to be handled writes the newest state. If your traffic is heavy, fetch the object before opening the transaction so the database isn't waiting on a network call.

Idempotency keys on the way out#

The same problem runs the other way. When you call Stripe and the connection fails, you can't tell whether the charge went through, and retrying blindly can charge twice. Send an idempotency key; Stripe answers a repeated key with the original result instead of acting again:

const paymentIntent = await stripe.paymentIntents.create(
  { amount: 4900, currency: 'usd', customer: customerId },
  { idempotencyKey: `order-${order.id}-payment` },
);

Build the key from your own record, the order, rather than a fresh UUID for each attempt. A new key on every retry switches the protection off. Stripe keeps keys for at least 24 hours (see idempotent requests in the API reference).

Test the unhappy paths#

The Stripe CLI forwards real test events to your machine, and prints the signing secret to use while it runs:

stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger customer.subscription.updated
stripe events resend evt_1Q2w3E4r5T6y   # the same event again

Resend an event you've already handled. Your logs should show a duplicate, and nothing in the database should change.

Checklist#

  • Signatures are verified on the raw request body
  • Event IDs are stored under a unique key, in the same transaction as their effects
  • Failures answer 5xx so Stripe retries; duplicates answer 200
  • Slow work goes on a queue and the response goes out in seconds
  • State comes from the API, not from the order events arrived in
  • Every write to Stripe sends an idempotency key built from your own IDs

FAQ#

Why does Stripe send the same webhook event twice?#

Stripe delivers webhooks at least once. If your endpoint times out, returns an error, or its response is lost on the way back, Stripe sends the event again, and in live mode it keeps retrying for up to three days. Duplicates are normal, so the handler has to expect them.

How do I make a Stripe webhook handler idempotent?#

Record each event's ID in a table where an ID can only appear once, in the same database transaction as the work the event triggers. If the insert finds the ID already there, the event was handled before, and the handler returns without doing anything.

What should a Stripe webhook endpoint return?#

A 2xx as soon as the event is verified and recorded, duplicates included. Return a 5xx only when processing failed and you want Stripe to retry, and a 400 when the signature check fails.

How long does Stripe keep idempotency keys?#

At least 24 hours. Build each key from your own record, such as the order ID, so every retry of the same operation sends the same key.

Why is my Stripe webhook signature verification failing?#

Usually because the body was parsed before verification: Stripe signs the raw bytes, so express.json() or a framework's body parser must not touch that route. The other common cause is the wrong secret. stripe listen prints its own whsec_ secret, and test and live endpoints each have their own.

What is Stripe's webhook retry policy?#

In live mode, Stripe retries a failed delivery for up to three days with exponential backoff. In a sandbox it retries three times over a few hours. Any 2xx response counts as success; timeouts, redirects and 4xx or 5xx responses count as failures and are retried.

Does Stripe send webhook events in order?#

No. Stripe doesn't guarantee that events arrive in the order they were created; invoice.paid can arrive before customer.subscription.created, for example. Treat each event as a signal, read the object's current state from the API when order matters, and use event IDs, not timestamps, to detect duplicates.

How do I stop Stripe from retrying a webhook?#

Return a 2xx response. Once a delivery succeeds, Stripe stops retrying that event. Resending an event by hand doesn't cancel the automatic retries, and disabling or deleting the endpoint before the next attempt stops future retries of that event.

Muhammad Huzaifa Abdulahad
Written by

Muhammad Huzaifa Abdulahad

Full-Stack Software & DevOps Engineer

I build SaaS platforms with Node.js, NestJS, TypeScript and PostgreSQL, and run them on Linux, Docker, AWS and CI/CD — from the first line of code to production.

Related articles

See all posts →

Have a complex idea? Let's build it together.

From MVPs to scalable products, let's turn your idea into reliable software built for real-world impact.