Webhook Idempotency: A Practical Guide to Handling Duplicate Events If you've ever stared at a "Resend" button in a webhook dashboard and hesitated — wondering whether clicking it...
The Developer S Guide To Webhook Idempotency Insta Webhook
Webhook Idempotency: A Practical Guide to Handling Duplicate Events
If you've ever stared at a "Resend" button in a webhook dashboard and hesitated — wondering whether clicking it will double-charge a customer or safely re-trigger a request that never actually landed — you've already felt the core problem this guide is about. Webhooks are not a "nice to have" reliability feature. They're a mandatory discipline for any backend that talks to the outside world.
Why Duplicates Are Guaranteed, Not Rare
Every major webhook provider delivers events on an at-least-once basis — never exactly once. Stripe's own documentation acknowledges that an endpoint might occasionally receive the same event more than once, and the same behavior shows up at the infrastructure level too: Amazon SQS Standard queues can redeliver a message if the server holding it becomes unavailable before the delete completes.
This isn't a bug any provider can engineer away. Exactly-once delivery across an unreliable network is a proven impossibility in distributed systems — the underlying result traces back to problems like the Two Generals Problem and the FLP impossibility result from the 1980s, which show that guaranteed consensus isn't achievable when a network can drop or delay messages. The practical takeaway: exactly-once is achievable as a processing guarantee, never as a delivery guarantee. The wire will sometimes hand you a duplicate. What you control is whether processing that duplicate produces a second side effect.
The most common trigger isn't even a flaky network — it's your own handler finishing the work but responding a few milliseconds too late, so the provider assumes failure and retries an operation that already succeeded.
The Foundation: A Stable Unique Identifier
Every reliable idempotency strategy starts the same way: store a unique identifier for each event, and refuse to process an identifier you've already seen.
Code example
Copy code
CREATE TABLE processed_webhooks (
idempotency_key TEXT PRIMARY KEY,
provider TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing',
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
When a webhook arrives, your application inserts the idempotency key into this table. If the insert fails on the unique constraint, you know immediately you've seen this event before.
Where that identifier comes from differs by provider, and getting this detail wrong quietly breaks your deduplication:
Provider Stable ID Retry behavior
Stripe event.id (e.g. evt_1OxYzA2eZvKYlo2C) — same across every retry Retries with exponential backoff for up to 3 days in live mode; disables the endpoint after that if it keeps failing
Shopify X-Shopify-Webhook-Id header Retries up to 19 times over 48 hours if it doesn't get a 2xx within 5 seconds
GitHub X-GitHub-Delivery GUID Does not auto-retry failed deliveries at all; manual or API redelivery reuses the same GUID
Standard Webhooks-compliant senders (Svix, and others) webhook-id header, stable across retries Varies by sender; the spec recommends the receiver dedupe on this header
That last row matters more each year. Standard Webhooks is an open specification, stewarded by the webhook infrastructure company Svix together with partners including Zapier, Twilio, Supabase, Lob, Mux, ngrok, and Kong, that standardizes three headers — webhook-id, webhook-timestamp, and webhook-signature (HMAC-SHA256 by default). The spec explicitly recommends using the webhook-id header as your idempotency key. It's been adopted by a growing list of platforms — reporting suggests OpenAI, Anthropic, Google Gemini, Twilio, PagerDuty, and Supabase are among them — specifically so that a single verification-and-dedup implementation works across every compliant provider instead of every API reinventing its own convention.
The Naive Approach — and Why It Breaks
A first-pass implementation usually looks like this:
Code example
Copy code
SELECT * FROM processed_webhooks WHERE idempotency_key = 'evt_123';
-- if it exists, return 200 OK
-- if not, run the business logic, then:
INSERT INTO processed_webhooks VALUES ('evt_123');
This is a race condition waiting to happen. If a network hiccup causes the provider to fire the same webhook twice within a few milliseconds of each other, both requests can run the SELECT simultaneously, both see "not processed yet," and both execute the business logic. A duplicate charge or duplicate email is the result.
The fix is to make the "check" and the "claim" a single atomic database operation — never check-then-act.
A Correct, Concurrency-Safe Implementation
Here's a pattern that closes the race condition, using a Node.js/Express handler and a PostgreSQL transaction. The key change from the naive version: the INSERT ... ON CONFLICT DO NOTHING RETURNING clause tells you, atomically, whether this request was the one that claimed the key.
Code example
Copy code
app.post('/webhook/stripe', async (req, res) => {
const event = req.body;
const idempotencyKey = event.id;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Atomically claim the key. If a row is returned, this
// transaction is the first (and only) one holding it.
const insertResult = await client.query(`
INSERT INTO processed_webhooks (idempotency_key, provider, status)
VALUES ($1, 'stripe', 'processing')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;
`, [idempotencyKey]);
const claimedByThisRequest = insertResult.rowCount > 0;
if (!claimedByThisRequest) {
// Someone else already claimed this key. Lock the row so we
// see its true status — this blocks until any concurrent
// transaction holding the lock commits or rolls back.
const { rows } = await client.query(`
SELECT status FROM processed_webhooks
WHERE idempotency_key = $1
FOR UPDATE;
`, [idempotencyKey]);
if (rows[0].status === 'completed') {
await client.query('COMMIT');
return res.status(200).send('Already processed');
}
// Still mid-flight (or a prior attempt crashed before
// finishing). Ask the provider to retry later instead of
// racing whatever is currently holding the lock.
await client.query('ROLLBACK');
return res.status(409).send('Concurrent processing in progress');
}
// ==========================================
// 🚀 EXECUTE ACTUAL BUSINESS LOGIC HERE 🚀
// ==========================================
await processStripeEvent(event);
await client.query(`
UPDATE processed_webhooks
SET status = 'completed'
WHERE idempotency_key = $1;
`, [idempotencyKey]);
await client.query('COMMIT');
return res.status(200).send('Success');
} catch (error) {
await client.query('ROLLBACK');
console.error('Webhook processing failed', error);
return res.status(500).send('Internal Server Error');
} finally {
client.release();
}
});
Two supporting habits make this even more robust in production:
Make the side effects idempotent too, not just the dedup check — use upserts instead of plain inserts (ON CONFLICT ... DO UPDATE), so that anything which slips past the idempotency table is still harmless.
Set a TTL on how long you retain keys — long enough to cover the provider's full retry window plus a safety margin (a week for Stripe, a few days for Shopify is a reasonable rule of thumb), so manual replays months later still get deduped rather than silently expiring out of the table.
Acknowledge First, Process Later
Even a perfectly locked database can't save you if your business logic — generating a PDF, calling a third-party API, sending an email — takes longer than the provider's timeout window. Stripe expects a response within roughly 15 seconds; Shopify's window is 5 seconds. Miss it, and the provider assumes failure and retries, even though your handler is still working.
The fix is to decouple receiving the webhook from processing it:
Acknowledge: verify the signature, persist the raw payload and idempotency key, and return 200 OK immediately.
Process: have an asynchronous worker consume the saved event from a queue (SQS, RabbitMQ, Kafka, or a simple database-backed job table) at its own pace.
Because the provider gets its 200 in milliseconds, it never sees a timeout, which is one of the largest sources of network-induced duplicate deliveries in the first place.
Idempotency Isn't Only a Correctness Feature
It's worth calling out a second benefit that's easy to overlook: idempotency is also a security control. A signed webhook that gets captured — from a leaked log file, an intercepted staging environment, a screenshot pasted into Slack — remains a technically valid, correctly signed request forever unless your verification includes a timestamp-tolerance check. Idempotency is what keeps a replayed, technically-valid webhook from re-fulfilling an order five times, three days apart. Signature verification proves the event is authentic; idempotency limits what an authentic-but-replayed event can actually do.
Operational Visibility: The Manual Retry Problem
Solid code solves the automatic case. It doesn't solve the operational one: a developer or support engineer looking at a failed delivery in a provider's dashboard, deciding whether to click "Resend." Standard provider dashboards only show the HTTP status code they received — they have no visibility into what your application's idempotency table actually did with that request. That's a real gap, and it's why a category of webhook-relay and management tools has grown up around it.
Two relevant approaches exist:
Idempotency-aware relay dashboards. Tools like InstaWebhook sit between the provider and your application, tracking each event through explicit states — received, queued, attempted, retried, delivered, dead-lettered — and surfacing delivery history and idempotency context before you replay an event, rather than leaving you to guess. This class of tool is most useful when you want a durable ingestion layer and safer manual replay without building that machinery yourself.
Full webhook-lifecycle platforms. Broader players — Svix (primarily for sending webhooks to your own customers, and the steward of the Standard Webhooks spec), Hookdeck and Convoy (primarily for receiving), and Hooklistener (webhook inspection and replay-testing) — cover overlapping but distinct parts of the send/receive lifecycle, with the market increasingly blurring the line between the two.
None of these tools remove the need for the database-level idempotency work above — they add operational visibility and safer replay semantics on top of it, which matters most once you have more than one engineer touching production webhook failures.
Conclusion
As webhooks continue to be the default way systems notify each other of events, assuming perfect network reliability is a design flaw, not an edge case. Providers will send duplicates, networks will partition, and deployments will interrupt in-flight requests — this is normal, expected behavior, not a rare failure mode.
The discipline that makes this manageable has four layers: a stable, provider-correct unique identifier; an atomic database claim instead of a check-then-act race; decoupled acknowledge-then-process handling so slow business logic never causes a timeout-triggered retry; and, where the team is large enough to need it, operational tooling that makes manual replay a safe, informed decision rather than a guess. Get those four right, and duplicate webhook delivery stops being an incident and goes back to being the background noise it was always going to be.
Sources consulted:
Stripe — Idempotent requests API reference
Stripe — Advanced error handling
Hookdeck — How to Implement Webhook Idempotency
Hooklistener — Webhook Idempotency and Deduplication
Standard Webhooks specification (GitHub)
Svix — Announcing Standard Webhooks
Svix Docs — Verifying Webhooks
InstaWebhook — Docs and product pages
APIScout — Best Webhook Management APIs in 2026
DigitalApplied — Webhook Reliability 2026: Idempotency & Retry Reference
Top comments (0)