All Webhook Errors

Webhook Duplicate Event Received — Causes & Fixes

Receiving the same webhook event multiple times is not a bug — it's the expected behavior under at-least-once delivery semantics. Every major webhook provider retries on failure, which means every event will be delivered at least once and possibly more. The fix is not to stop the duplicates — it's to make your handler idempotent so duplicates are no-ops.

Why Duplicates Happen

  • Your handler took 31 seconds to respond. Provider's 30s timeout fired. They retry — but your handler did succeed, so now you've processed it once and the retry will arrive too.
  • Your handler returned 200 but the response was lost in the network. Provider didn't see the ack, retries.
  • Provider's own delivery system has multiple workers; rare race conditions can cause two simultaneous deliveries.
  • You manually re-fired a delivery from the provider's dashboard for testing and forgot to clear it.

The Core Pattern: Idempotency by Event ID

Every event has a unique ID — Stripe's evt_..., GitHub's X-GitHub-Delivery UUID, Shopify's X-Shopify-Webhook-Id. Your handler stores every event ID it has fully processed. On every inbound webhook, the first thing you do is check whether the ID is in the store. If it's there, return 200 immediately. If not, process the event and write the ID to the store as part of the same transaction.

// Postgres-backed idempotency
async function handleWebhook(req, res) {
  const event = verifyAndParse(req)

  // Try to claim the event ID. If another process already did, this is
  // a duplicate and INSERT returns 0 rows.
  const { rowCount } = await db.query(
    'INSERT INTO processed_events (event_id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING',
    [event.id, event.type]
  )

  if (rowCount === 0) {
    return res.status(200).send('duplicate-noop')
  }

  // We claimed it — now do the work, in the same transaction
  await db.transaction(async (tx) => {
    await processEvent(event, tx)
    await tx.query('UPDATE processed_events SET completed_at = NOW() WHERE event_id = $1', [event.id])
  })

  res.status(200).send('processed')
}

Redis Alternative

// SET NX with TTL is a single atomic op
const claimed = await redis.set(
  \`evt:\${event.id}\`, '1',
  'EX', 60 * 60 * 24 * 7,    // 7-day TTL
  'NX'                        // only set if absent
)
if (!claimed) return res.status(200).send('duplicate')
// Process event…

The Subtle Failure Mode

A naïve handler does charge(); send_email(); store(event_id). If it crashes between the charge and the store, the next retry charges again. The fix is to either (a) do the side effect and the dedup-store write in the same transaction, or (b) use idempotency keys on the side effect itself so re-running is safe.

// Idempotent downstream call using the event ID
await stripe.refunds.create(
  { payment_intent: event.data.object.id },
  { idempotencyKey: event.id }    // Stripe API dedupes within 24h
)

Storage Retention

Keep event IDs for at least as long as the source's max retry window. Stripe retries 3 days, so 7 days minimum (with margin for late retries and clock skew). After that, age out — eternal storage isn't necessary. A daily cleanup job: DELETE FROM processed_events WHERE created_at < NOW() - INTERVAL '7 days'.

How to Reproduce

In WebhookWhisper or your provider's dashboard, manually re-fire a delivery. Your handler should return 200 immediately on the second hit (with a "duplicate-noop" log line) without re-running business logic. If it re-runs, idempotency is broken.

Provider-Specific Retry Schedules

ProviderMax retry durationIdempotency key field
Stripe3 daysid (e.g. evt_1Mn...)
GitHub~24 hours, 8 attemptsX-GitHub-Delivery
Shopify48 hours, 19 attemptsX-Shopify-Webhook-Id
Slack~30 minutes, 3 attemptsevent_id in payload
TwilioConfigurable per-resourceSid

Frequently Asked Questions

Can I tell which delivery is the duplicate?

Some providers expose attempt count. Stripe has it in the dashboard. GitHub has X-GitHub-Hook-ID + X-GitHub-Delivery — same hook, different delivery = retry. Don't depend on attempt counts for correctness — depend on event ID dedup.

What if I want to process duplicates intentionally?

You probably don't. The risk is double-charging, double-emailing, double-revoking access. Treat the event ID as the source of truth for 'this happened once.'

How long until the source stops retrying?

Varies — see the table above. Plan idempotency retention to outlive the longest retry window plus a buffer for late retries and clock skew.

Debug This Error in Real Time

WebhookWhisper captures every webhook request with full headers, body, and timing — so you can see exactly what the provider sent and reproduce the error instantly.

Start Debugging Free
Webhook Duplicate Event Received — Causes & Fixes (2026) | WebhookWhisper