Every major webhook provider — Stripe, GitHub, Shopify, Twilio, Slack — delivers the same event more than once on purpose. They call it at-least-once delivery. If your handler isn't designed for it, you'll get double-charged customers, duplicate GitHub PR labels, and Slack notifications fired twice. The fix is small and underrated: design every handler to be safe under repeated delivery.
Why duplicates happen
Providers retry when they don't get a clean 2xx in time. The retry window varies — Stripe: up to 3 days with exponential backoff, GitHub: 8 attempts over ~8 hours, Shopify: 48 hours. Most of the time, the original delivery did succeed; the network just lost the response on its way back. Provider doesn't know that. So it retries. So your handler runs twice.
This isn't a bug to be fixed; it's a property of the system. Build for it.
The idempotency pattern that actually works
Every event from a major provider includes a unique event ID. Stripe: evt_xxx. GitHub: X-GitHub-Delivery header (a UUID). Shopify: X-Shopify-Webhook-Id. Store the ID the first time you process the event. On retry, look it up — if you've seen it, return 200 without re-running the handler.
async function handleWebhook(event) {
const eventId = event.id;
// Atomic insert-if-not-exists
const inserted = await db.processedEvents.insertIfNew({
eventId,
receivedAt: new Date(),
});
if (!inserted) {
// We've handled this one. Be polite about it.
return { status: 200, body: 'already processed' };
}
// First time we've seen it. Do the work.
await actuallyHandleEvent(event);
return { status: 200 };
}
The shape that matters: the insert and the handler logic must be atomic together, or the insert must come first. If you call handleEvent first and then insert the ID, a crash mid-handler leaves no record and the retry will reprocess.
Where to store the dedup key
- Same DB transaction as the business write. Best. The dedup key and the side-effect either both commit or both don't. Use a unique constraint on the event ID column.
- Redis with a long TTL. Fine for low-stakes events. Match the TTL to the longest retry window of any provider you integrate with — start at 7 days.
- In-memory cache. Don't. Your process restarts, the cache is gone, and the next retry succeeds at re-running a handler that already ran.
What "not idempotent" looks like in practice
A real example we hit: a Stripe invoice.payment_succeeded handler that called account.credit(amount) directly. Each successful payment added a balance. Most of the time exactly once. Occasionally twice when a retry caught the same event. We found it because a customer politely emailed asking why his invoice for $50 had credited his account for $100.
The fix wasn't to change Stripe's retry policy or fight the provider. It was a unique index on events_processed.stripe_event_id and a guard at the top of the handler. The whole patch was eleven lines.
Handler timing matters too
Providers have hard timeouts. GitHub: 10 seconds. Discord interaction endpoints: 3 seconds. Stripe and Shopify are more forgiving (~30 seconds) but you'll still get retries if you're slow. The pattern is the same regardless: respond fast, do work async.
async function handleWebhook(event) {
if (await alreadyProcessed(event.id)) {
return { status: 200 };
}
// Don't await the heavy work — queue it.
await queue.enqueue('process-stripe-event', event);
// 200 immediately. Worker picks up the event.
return { status: 200 };
}
The queue worker handles the slow stuff. The webhook handler is basically a deduper + a queue producer. This pattern stays correct under retries because the queue producer itself is idempotent (insert-if-new on event ID), and any double-enqueue is caught by the same guard in the worker.
Test it locally, please
Testing idempotency is what tunnel replay was made for. Capture one event delivery in your tunnel's request history and replay it ten times against your local handler. The first call should write to your database. The next nine should return 200 without touching it. Watch your DB logs to confirm.
This is the test most teams skip and most teams shouldn't.
Provider-specific retry policies
- Stripe: retries for 3 days, exponential backoff. Skips after 16 attempts.
- GitHub: 8 attempts, finished within ~8 hours. Stops marking the delivery as failed after that.
- Shopify: 48 hours, exponential backoff. Disables webhook subscription after 19 consecutive failures over 48 hours.
- Twilio: single retry by default, configurable to up to 3.
- Slack: 3 retries with backoff (1s, 30s, 5min).
Set your dedup TTL to the longest window you care about, plus a buffer.
The bottom line
Duplicate delivery is a contract, not a defect. If you've shipped a webhook handler that calls increment, send_email, or charge_card without a dedup key, you've shipped a bug. Patching it is small. Catching it after a customer complaint is expensive.
For more on testing this end to end, the replay debugging guide covers the workflow. Signature verification is upstream of this — the signature guide covers that. Join the PortPreview waitlist for tunnel + replay built around this exact workflow.