Webhook idempotency means your handler processes each event only once — even if the same event is delivered multiple times. Stripe and Shopify may redeliver a webhook if your endpoint was slow or returned a non-2xx, so a handler that isn't idempotent risks charging customers twice, fulfilling orders twice, or creating duplicate records.
Webhook Idempotency: Why You Must Handle Duplicate Events
Your Stripe checkout.session.completed handler fires. It provisions the customer's account, sends a welcome email, and creates a subscription record in your database. Everything works. Then, 30 seconds later, the same event arrives again — because your endpoint was slow to respond and Stripe's first delivery timed out. Your handler fires a second time. The customer gets two welcome emails. Your database has a duplicate subscription row. If you're incrementing usage counters or charging a card, it happens twice.
This is the problem idempotency solves. It's not theoretical — any webhook platform that retries on failure can and will redeliver the same event. Here's how to implement idempotency correctly.
Why do platforms redeliver webhook events?
Webhook platforms retry delivery when they don't receive a clean success signal from your endpoint. Specifically:
- Timeout: Stripe considers a delivery failed if your endpoint doesn't respond within 30 seconds. Shopify's limit is also 5 seconds for the initial response. A slow database query or synchronous API call in your handler is enough to trigger a retry.
- Non-2xx response: Any HTTP status outside the 200–299 range is treated as a failure. A 500 during an exception, a 422 from a validation error, or a 503 during a deploy — all trigger retry logic.
- Manual redeliver: You or a teammate clicks "Resend" in the Stripe Dashboard or uses a replay tool. This is intentional but still sends the same event a second time.
- Platform bugs and at-least-once delivery: Both Stripe and Shopify document explicitly that their delivery guarantee is at least once, not exactly once. Even a successful delivery can theoretically be followed by a duplicate.
Stripe retries on an exponential backoff schedule for up to 72 hours — up to 11 attempts per event. Shopify retries 19 times over 48 hours. Every retry is a potential duplicate.
What is an idempotency key and where does each platform put it?
An idempotency key is a unique, stable identifier for a specific event. The same event delivered multiple times always carries the same key. You use it to check whether you've already processed that event before doing any work.
- Stripe: Every event object has an
idfield of the formevt_1abc.... This ID is globally unique and stable across retries. The redelivered event is the same object with the same ID. - Shopify: The
X-Shopify-Webhook-IdHTTP request header contains a UUID unique to each delivery attempt. Note that Shopify does not guarantee the same UUID on retry — so for Shopify you may also want to consider the topic + resource ID combination as a secondary deduplication signal. - GitHub: The
X-GitHub-Deliveryheader contains a UUID per delivery. Like Shopify, this may differ between retries, so the delivery + event type combination is your most reliable key.
How do you implement idempotency in a webhook handler?
The pattern is straightforward: before doing any work, record the event ID in a database table. If the record already exists, skip processing and return 200. Here's the implementation in Node.js:
-- SQL: create the idempotency table
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
platform TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); // Node.js: idempotent Stripe webhook handler (Express) import Stripe from 'stripe'; import { db } from './db'; app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; let event; try { event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET); } catch (err) { return res.status(400).send(`Webhook signature error: {err.message}`); } // Idempotency check — respond 200 immediately if already processed const existing = await db.query( 'SELECT 1 FROM processed_events WHERE event_id = $1', [event.id] ); if (existing.rows.length > 0) { return res.status(200).json({ status: 'already_processed' }); } // Mark as processed before doing any work await db.query( 'INSERT INTO processed_events (event_id, platform) VALUES ($1, $2)', [event.id, 'stripe'] ); // Respond 200 immediately, process asynchronously res.status(200).json({ received: true }); // Queue the real work — do not await this before responding processStripeEvent(event).catch(console.error); });
The PRIMARY KEY constraint on event_id means that even under concurrent load — two simultaneous deliveries of the same event — only one INSERT will succeed. The other will throw a unique constraint violation, which you should catch and treat as "already processed."
Why should you respond 200 immediately and process asynchronously?
Stripe and Shopify have short response timeouts. If your handler does everything synchronously — verifies the signature, inserts the idempotency record, runs business logic, calls downstream APIs, sends emails — it's very easy to exceed the timeout. When you do, the platform marks the delivery as failed and retries, creating the exact duplicate problem you're trying to prevent.
The correct pattern is:
- Verify the signature (fast — in-memory HMAC check)
- Check and insert idempotency key (fast — single indexed DB query)
- Respond
200 OK - Queue the actual work (pg-boss, BullMQ, SQS, or even
setImmediatefor simple cases)
The idempotency check must happen synchronously before the 200 response, not inside the async job. Otherwise two concurrent deliveries can both receive 200 and both enqueue the job.
What operations are unsafe without idempotency?
Not all operations are equally dangerous to duplicate. Understanding the difference helps you prioritize:
- Non-idempotent (dangerous to duplicate): charging a payment method, sending an email, sending an SMS, incrementing a counter, inserting a new database row, creating a third-party resource via API (e.g. adding a Mailchimp subscriber). Each of these produces a visible, often irreversible side effect.
- Idempotent (safe to duplicate): setting a field to a fixed value (
UPDATE subscriptions SET status = 'active'), upserting a record based on a unique key, checking a boolean flag. Running these twice produces the same result as running them once.
Even if your core business logic is idempotent, side effects like emails and Slack notifications almost never are. The idempotency table protects all of them.
What happens if you don't implement idempotency?
The failure modes depend on what your handler does:
- Duplicate charges: If your handler triggers a payment or saves a card charge, a retried event can charge the customer twice.
- Duplicate order fulfillment: A retried
orders/paidShopify event can ship a second package for the same order. - Duplicate emails: Welcome emails, receipts, confirmation messages — all sent twice or more.
- Corrupt counters: If you increment
users.eventsThisMonthorinventory.quantityon each event, duplicates inflate or deflate those numbers incorrectly. - Duplicate records: Inserting a user, subscription, or invoice row without checking for the event ID first creates duplicate rows that are hard to reconcile after the fact.
FAQ: Webhook idempotency
What is an idempotency key in webhooks?
id field (e.g. evt_1abc...). For Shopify, it's the X-Shopify-Webhook-Id request header. You store this key in a database table and check it before processing — if the key already exists, skip processing and return 200 immediately.How does Stripe handle duplicate webhook deliveries?
id — so checking for that ID in a processed_events table is sufficient to deduplicate all retry-driven duplicates.How do I make my webhook handler idempotent?
Know when every webhook fails — before you accidentally process a duplicate. Connect Stripe to Webhook Guardian and get alerted within 5 minutes of any failed delivery, with event payload and one-click replay. Start free for 14 days →
Also read: Webhook reliability best practices · How to know when a Stripe webhook fails · Stripe webhook monitoring