Webhook Guides·8 min read·2025-07-13·Colm Byrne, Technical Product Manager

How to Stop Stripe Webhooks From Creating Duplicate Orders

Stripe's retry policy is designed to ensure delivery. Without idempotency in your handler, it also ensures duplicate orders. Here's the fix.

You deployed a change on Thursday at 5pm. Your webhook handler broke for 20 minutes. Stripe retried every event that returned a non-2xx response. When your handler came back up, some of those retries processed on top of events that had already been partially handled.

On Friday morning, you had 12 customers with duplicate orders.

This is the Stripe webhook duplicate problem. It's incredibly common. Here's how to prevent it.

Why duplicates happen

Stripe's retry policy is designed to guarantee delivery, not idempotency — if your handler returns 500, Stripe will retry the same event up to 87 times over 72 hours. The Stripe webhook documentation covers the delivery model, and Stripe webhook best practices address handler design. For an in-depth look at the race condition most implementations miss, see Stripe webhook duplicate events.

Retries are the right behavior — you want missed events to eventually get processed. The problem is what happens to your handler on retry:

// Naive handler — creates a duplicate on retry
app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(...)

  if (event.type === 'payment_intent.succeeded') {
    // This runs on EVERY delivery attempt, including retries
    await db.orders.create({
      stripe_payment_intent_id: event.data.object.id,
      amount: event.data.object.amount,
      status: 'paid'
    })

    await stripe.subscriptions.update(customerId, { tier: 'pro' })
  }

  res.sendStatus(200)
})

On retry: second order created, subscription updated again (may or may not be a problem depending on your update logic).

The idempotency key pattern

The fix: check whether you've already processed this event before doing anything.

app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(...)

  if (event.type === 'payment_intent.succeeded') {
    const stripeEventId = event.id  // This is your idempotency key

    // Check if already processed
    const existing = await db.webhookEvents.findOne({
      stripe_event_id: stripeEventId
    })

    if (existing) {
      // Already processed — return 200 so Stripe stops retrying
      return res.sendStatus(200)
    }

    // Process atomically — record + action in one transaction
    await db.transaction(async (trx) => {
      // Record that we're processing this event
      await trx.webhookEvents.insert({
        stripe_event_id: stripeEventId,
        processed_at: new Date()
      })

      // Do the actual work
      await trx.orders.create({ ... })
      await trx.subscriptions.update({ ... })
    })
  }

  res.sendStatus(200)
})

The critical elements:

  1. Use event.id as your idempotency key — Stripe guarantees this is unique per event
  2. Check before acting — not after
  3. Record + action in the same transaction — prevents the race condition where two requests process simultaneously

The race condition to watch out for

The basic pattern above has a subtle bug under concurrent load: two retry requests arriving simultaneously can both pass the "already processed?" check before either one writes the record.

Fix with a unique constraint:

CREATE TABLE webhook_events (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  stripe_event_id text UNIQUE NOT NULL,  -- This is the guard
  processed_at timestamptz NOT NULL DEFAULT now()
);

Now the second insert will fail with a unique constraint violation, which you catch and treat as "already processed":

try {
  await db.transaction(async (trx) => {
    await trx.webhookEvents.insert({ stripe_event_id: stripeEventId })
    await trx.orders.create({ ... })
  })
} catch (error) {
  if (error.code === '23505') {  // unique_violation
    return res.sendStatus(200)  // Already processed, not an error
  }
  throw error  // Real error — let Stripe retry
}

What about replay?

Idempotency becomes especially critical during recovery scenarios. If your app was down for an hour and you're replaying 40 events, your idempotency guard is the only thing standing between you and 40 duplicate orders. To understand what a silent webhook failure looks like before you even get to replay, read silent webhook failure patterns.

But there's a layer above idempotency that most tools miss: knowing which events actually need replaying — because replaying events with confirmed outcomes is how you create new duplicates even with idempotency guards in place.

Learn how to test Stripe webhooks locally before you hit these issues in production.

If 15 of those 40 events were partially processed before the outage, you don't want to replay them — you want to investigate whether they need manual remediation.

HookTunnel's guardrailed replay addresses this at the tooling layer: it won't replay an event with a confirmed receipt (Applied Confirmed state). But your application-level idempotency guard is still required as a defence in depth.

Summary

  1. Use event.id as your idempotency key
  2. Check before processing, not after
  3. Record the event ID and process atomically in a single transaction
  4. Add a database unique constraint as your concurrency guard
  5. Treat unique constraint violations as "already processed" — return 200

Webhooks arrive more than once. Your handler needs to be ready for that.


Want to know which of your webhook events were actually applied — not just delivered? HookTunnel's outcome receipts give you cryptographic proof, per event.

Stop guessing. Start proving.

Generate a webhook URL in one click. No signup required.

Get started free →