Skip to content
All articles
9 min read

Stripe Webhook Idempotency in Node.js: Process Once

MD Rakibul Islam RakibMD Rakibul Islam RakibFull-stack developer, DevOps & Linux engineer
Stripe Webhook Idempotency in Node.js: Process Once

Stripe webhook idempotency means each event changes your data once: insert event.id into a unique table in the same transaction as the update it causes.

I learned this on a payment bug, not from the docs. On a telemedicine platform I rebuilt, a payment gateway's browser redirect and its server callback could both confirm the same payment, and the doctor's share was paid twice. The fix was to make every confirmation "claim" the payment first, so the work runs exactly once. Stripe webhooks have the same shape of problem. Below is the pattern I use, tested against PostgreSQL 16 on 10 October 2026, with the real output.

Key takeaways

  • Stripe can deliver the same event more than once, retries failed deliveries for up to three days in live mode, and doesn't guarantee order.
  • Claim the event inside the transaction. INSERT ... ON CONFLICT DO NOTHING on event.id, then do the update, then commit. A crash rolls back both, so the retry still works.
  • Add a second guard on the business object. Stripe sometimes sends two different events for one change, so also put a unique key on the invoice or payment ID.
  • Duplicate invoices often start on your side. A double-clicked "Subscribe" creates two subscriptions. Send an idempotencyKey with every create call.
  • Prefer writes that are safe to repeat, like "plan valid until X", over increments like "credits + 100".

Why Stripe sends the same event twice

Stripe's webhook docs are clear about delivery behaviour, and each point is a way to get duplicates:

  • Retries. If your endpoint times out or answers with anything other than 2xx, Stripe tries again, for up to three days with exponential back-off in live mode (three times over a few hours in a sandbox). If your code updated the database and then crashed before answering, the retry runs it again.
  • Occasional duplicate deliveries. Stripe says an endpoint might occasionally receive the same event more than once, and recommends logging processed event IDs.
  • Two events for one change. In some cases Stripe generates two separate Event objects. They have different IDs, so only a check on the object ID plus event type catches them.
  • Manual resends. Anyone on your team can press Resend in the Dashboard, or run stripe events resend.
  • No ordering. invoice.paid can arrive before customer.subscription.created, and two events can share the same created second.

Signature checks don't help here: a retry is a genuine, correctly signed event. If your signature check itself is failing, fix that first with my Stripe signature verification guide.

Two different "duplicate" bugs

"Stripe charged twice" and "we have duplicate invoices" usually mean one of two different bugs:

  1. Your webhook handler ran twice. Stripe has one invoice, but your app gave two months of access, two sets of credits or sent two receipts. The fix is on the receiving side and takes up most of this post.
  2. Your code created two Stripe objects. Two subscriptions, two Checkout Sessions or two charges, because a request was retried or a button was clicked twice. Stripe then really does send two invoices. The fix is on the sending side, with idempotency keys.

The schema: two unique keys

CREATE TABLE stripe_events (
  event_id     text PRIMARY KEY,          -- evt_...
  type         text NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE invoice_payments (
  stripe_invoice_id text PRIMARY KEY,     -- in_...  second guard
  account_id        text NOT NULL,
  amount_paid       integer NOT NULL      -- cents, never floats
);

The database enforces both rules. Application code that "checks first, then inserts" has a gap between the check and the insert, and two deliveries arriving together will both pass the check.

The handler: claim, update, commit

import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export async function handleEvent(event) {
  const db = await pool.connect();
  try {
    await db.query('BEGIN');
    const claim = await db.query(
      'INSERT INTO stripe_events (event_id, type) VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING',
      [event.id, event.type],
    );
    if (claim.rowCount === 0) {        // someone already processed (or is processing) it
      await db.query('ROLLBACK');
      return 'duplicate';
    }

    if (event.type === 'invoice.paid') {
      const inv = event.data.object;
      const pay = await db.query(
        'INSERT INTO invoice_payments (stripe_invoice_id, account_id, amount_paid) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
        [inv.id, inv.metadata.account_id, inv.amount_paid],
      );
      if (pay.rowCount === 1) {        // first time we see this invoice
        await db.query(
          'UPDATE accounts SET credits = credits + 100, plan_until = to_timestamp($2) WHERE id = $1',
          [inv.metadata.account_id, inv.lines.data[0].period.end],
        );
      }
    }
    await db.query('COMMIT');
    return 'processed';
  } catch (err) {
    await db.query('ROLLBACK');
    throw err;                          // route answers 500, Stripe retries later
  } finally {
    db.release();
  }
}

Wire it into your route after stripe.webhooks.constructEvent() has verified the raw body. Answer 200 for both processed and duplicate, and 500 only when handleEvent throws, so Stripe retries real failures and stops retrying duplicates.

The account ID comes from metadata that you set when you created the subscription. Mapping by Stripe customer ID works just as well; just don't trust an email address from the event to find the account.

Stripe evt_2 ×3 stripe_events event_id unique evt_2 accounts credited once 2 more inserts: ON CONFLICT, skip
Concurrent deliveries of one event race to insert the same event ID. Postgres lets exactly one win; the others find the row, do nothing and answer "duplicate".

What happened when I tried to break it

I ran the handler against a real Postgres 16 container with four scenarios:

first delivery failed: simulated crash mid-handler
after crash credits = 0 events = 0
retry -> processed credits = 100
10 concurrent: 1 processed, 9 duplicate; credits = 200
second event, same invoice -> processed credits = 200
constructEvent ok -> evt_4 processed credits = 300
  • Crash after the claim: the error rolled back the claim too. No event row, no credits, so Stripe's retry processed it normally. This is why the claim must be inside the same transaction.
  • Ten copies at once: exactly one processed. The other nine waited on the row lock, saw the committed row and returned duplicate.
  • A second event ID for an invoice already paid: it was recorded, but the invoice_payments key stopped a second credit.
  • A signed payload: I generated a real signature header with stripe-node's generateTestHeaderString, verified it with constructEvent, and the event went through the same path.

The two wrong orders are worth spelling out. Insert the event ID in its own transaction first, crash before the update, and the event is lost forever: the retry sees "already processed". Update first and insert the ID afterwards, crash in between, and the retry credits the customer again.

Write updates that are safe to repeat

Look at the two writes in the handler. plan_until = to_timestamp(period_end) gives the same result no matter how many times it runs. credits = credits + 100 does not. Where you can, store the state Stripe tells you (paid until this date, on this plan) instead of adding or subtracting.

The same thinking handles out-of-order events. If customer.subscription.updated arrives late with old data, don't let it overwrite newer data blindly. Either compare a timestamp or version you saved, or fetch the current subscription from the API before you update it. Stripe's docs suggest exactly that when an event arrives before the objects it refers to.

Stop creating duplicate subscriptions in the first place

Every POST to Stripe's v1 API accepts an idempotency key. Send the same key twice and Stripe returns the first result instead of creating a second object:

const subscription = await stripe.subscriptions.create(
  {
    customer: account.stripeCustomerId,
    items: [{ price: priceId }],
    metadata: { account_id: account.id },
  },
  { idempotencyKey: `sub:${account.id}:${priceId}:${checkoutAttemptId}` },
);
  • Build the key from the user's intent, not a random value per request. A new random key on every click protects nothing. checkoutAttemptId is an ID you create when the checkout page loads.
  • Same key, same parameters. Stripe compares them and returns an error if a key is reused with different parameters.
  • Keys don't last forever. Stripe may remove keys once they are at least 24 hours old, after which the same key creates a new object. Your own database check is the long-term guard.
  • Add that database check: a partial unique index such as "one active subscription per account" turns a bug into a clear error instead of a second monthly charge.

Answer fast, do heavy work later

Stripe asks endpoints to return 2xx quickly, before any slow work. A handler like the one above, a few indexed writes, is fast enough to run inline. Sending emails, generating PDF invoices or calling other APIs is not. Commit the database change, then put those jobs on a queue (pg-boss, BullMQ or similar) keyed by the event ID, so a retried job can't send the receipt twice either.

Test it yourself

  • stripe listen --forward-to localhost:3000/api/webhooks/stripe, then stripe trigger invoice.paid to send a real test event.
  • Resend the same event with stripe events resend evt_... --webhook-endpoint=we_... and check that nothing changes the second time.
  • In unit tests, sign payloads with stripe.webhooks.generateTestHeaderString() and fire the same event ten times with Promise.all, like the test above.

Frequently asked questions

Does Stripe guarantee each webhook event is delivered only once?

No. Stripe retries failed deliveries and says an endpoint may occasionally receive the same event more than once. Your handler has to make duplicates harmless, usually by recording processed event IDs.

Is checking event.id enough to stop duplicate processing?

It stops repeated deliveries of the same event. It doesn't stop two separate events about the same change, which Stripe says can happen. Add a unique key on the object ID, such as the invoice or payment intent, for the actions that matter.

Should I use Redis instead of Postgres for webhook deduplication?

Only if the update itself lives in Redis. The claim and the business update must commit or fail together, which is easy when both are in the same database transaction and hard across two systems.

How long are Stripe idempotency keys kept?

Stripe may remove v1 idempotency keys once they are at least 24 hours old. A request with a removed key is treated as new, so keep your own database constraints as the permanent protection.

What should my webhook return for a duplicate event?

Return 200. The event was handled before, so there is nothing to retry. Returning an error makes Stripe keep sending it for days.

Payments behaving strangely in your app?

I build and fix Stripe and local payment gateway integrations in Node.js, NestJS and Next.js, including subscriptions, webhooks and revenue splits. See my web development services or send me the symptoms and I'll tell you where I'd look first.

MD Rakibul Islam Rakib

Written by

MD Rakibul Islam Rakib

Full-stack developer, DevOps engineer and Linux system administrator with 5+ years of production experience. I deploy, harden and fix servers and web apps for clients worldwide, and everything in this article runs on real servers I manage, including this site.

  • Stripe webhook idempotency
  • Stripe duplicate webhook event
  • prevent duplicate Stripe payment
  • Stripe idempotency key
  • Stripe subscription webhooks
  • PostgreSQL ON CONFLICT
  • Node.js payments