Webhook Idempotency and Deduplication: Stop Processing Events Twice

Published June 10, 202613 min read

Your webhook handler will receive the same event twice. Not might — will. Every major provider delivers webhooks at-least-once, which means duplicates are part of the contract, not an edge case. If your handler charges a card, sends an email, or decrements inventory on every delivery, you have a bug waiting for production traffic.

This article covers where duplicates actually come from, how to pick an idempotency key, the concrete dedup patterns in Postgres and Redis (and their failure modes), and how to test duplicate handling by capturing a real event and replaying it against your handler until you trust it.

Why Duplicate Webhooks Are Guaranteed

Webhook providers face a choice when a delivery is ambiguous: deliver at-most-once (and silently drop events when things go wrong) or at-least-once (and occasionally send the same event twice). Every serious provider picks at-least-once, because a dropped payment.succeeded is worse than a duplicated one. That decision pushes the deduplication problem onto you, the receiver.

The slow-but-successful response

This is the classic one. Your handler processes the event correctly but takes 11 seconds to respond. The provider gave up at 10 seconds, marked the delivery failed, and scheduled a retry. From your side: success. From their side: failure. The retry arrives and you process the same event again. Shopify enforces a 5-second response deadline, GitHub expects a response within 10 seconds — slow handlers generate duplicates constantly.

Network ambiguity

Your 200 response gets lost on the way back — a dropped connection, a timeout at a proxy, a load balancer restart. The provider never saw your acknowledgment, so it retries. There is no way for the sender to distinguish “handler never got it” from “handler got it but the response vanished.”

Provider-side redeliveries

Providers occasionally redeliver events after their own incidents or internal retries. Stripe's documentation is explicit that endpoints might receive the same event more than once and tells you to guard against duplicates.

Manual replays by humans

Someone on your team hits “resend” in the Stripe dashboard while debugging. Someone redelivers a GitHub webhook from the settings page. Someone replays a captured request from a debugging tool. All legitimate, all duplicates from your handler's point of view.

Retry schedules amplify this. If your endpoint flaps during an incident, a single event can arrive five or six times over hours. For the full picture of how providers back off and when, see our guide on webhook retries best practices.

What Providers Actually Send: Stripe, GitHub, Shopify

The good news: most providers give you a stable identifier for exactly this purpose. The details differ enough that it's worth being precise.

Stripe: the event ID in the payload

Every Stripe event has an id field like evt_1OxYzA2eZvKYlo2C that stays the same across every retry of that event. In live mode, Stripe retries failed deliveries with exponential backoff for up to three days, and the evt_ ID never changes. Dedup on it.

One source of confusion: Stripe also has Idempotency-Key headers for API requests yousend to Stripe. That's a different mechanism — for webhooks coming from Stripe, the event ID is your dedup key. Our Stripe webhooks implementation guide covers the full handler.

GitHub: the X-GitHub-Delivery header

Each delivery carries an X-GitHub-Delivery header with a GUID identifying that delivery. GitHub does notautomatically retry failed deliveries — but deliveries can be redelivered manually from the webhook settings page or programmatically via the REST API, and redeliveries share the original delivery's GUID (that's how the deliveries API groups them). Deduping on the GUID therefore catches redeliveries, which is usually what you want.

Because GitHub doesn't auto-retry, teams often build their own redelivery automation on top of the API — which makes idempotent handling more important, not less.

Shopify: the X-Shopify-Webhook-Id header

Shopify sends an X-Shopify-Webhook-Id header that uniquely identifies the event, and it's aggressive about retries: if your endpoint doesn't return a 2xx within 5 seconds, Shopify retries 19 times over the next 48 hours. A handler that's slow during a flash sale will see a wall of duplicates. Shopify's own docs tell you to use this header for deduplication. See receiving Shopify webhooks for the full setup.

If your provider sends nothing usable — some internal services and smaller SaaS tools don't — you'll need to derive a key yourself. That's next.

Choosing the Right Idempotency Key

An idempotency key is the value you use to answer one question: “have I seen this exact event before?” Getting the key wrong breaks dedup in both directions — too broad and you drop distinct events, too narrow and duplicates slip through.

Prefer the provider event ID

  • • Stable across retries by design — that's what it's for
  • • Survives payload changes (some providers re-render the payload on retry, so bodies can differ byte-for-byte)
  • • Cheap to extract and index
  • • Maps cleanly to the provider's dashboard when you're debugging

Hash the payload only as a fallback

  • • A SHA-256 of the raw body works when no ID exists
  • • Breaks if the provider includes timestamps or retry counters in the body — each retry hashes differently
  • • Falsely merges genuinely distinct events that happen to have identical bodies
  • • Hash the exact raw bytes, not a re-serialized JSON object — key ordering will betray you
import { createHash } from "crypto";

// Fallback only: derive a key from the exact raw bytes.
// Re-serializing parsed JSON changes key order and whitespace,
// so two identical events can hash differently.
const idempotencyKey = createHash("sha256")
  .update(rawBody)
  .digest("hex");

Scope the key

Event IDs are unique per provider, not globally. Prefix the key with the source: stripe:evt_1OxYzA... rather than the bare ID. If you run multiple environments against shared infrastructure, scope by environment too — a test-mode evt_ ID colliding with dedup state from another environment is a confusing afternoon.

Give the key a TTL

You don't need to remember every event forever — only as long as a duplicate can plausibly arrive. Work backwards from the provider's retry window: Stripe retries for up to 3 days, Shopify for 48 hours. A TTL of the retry window plus a safety margin (say, 7 days for Stripe, 72 hours for Shopify) bounds your storage while covering every automatic retry. Manual replays months later will slip past an expired key, which is one reason dedup-at-the-door shouldn't be your only defense — more on that below.

Implementation Patterns: Postgres and Redis

Two patterns cover the vast majority of real systems. Both hinge on the same property: the check and the claim must be a single atomic operation.

Pattern 1: Postgres unique constraint

Create a table whose primary key is the idempotency key, and let the database enforce uniqueness:

CREATE TABLE webhook_events (
  event_id    TEXT PRIMARY KEY,   -- "stripe:evt_..." scoped key
  source      TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

In the handler, insert and let the conflict tell you whether this is a duplicate:

const inserted = await db.query(
  `INSERT INTO webhook_events (event_id, source)
   VALUES ($1, 'stripe')
   ON CONFLICT (event_id) DO NOTHING
   RETURNING event_id`,
  [`stripe:${event.id}`]
);

if (inserted.rowCount === 0) {
  // Duplicate. Acknowledge with a 200 so the provider stops retrying.
  return res.status(200).send("ok");
}

await processEvent(event);
return res.status(200).send("ok");

Postgres has no native TTL, so prune the table on a schedule — a nightly cron is fine:

DELETE FROM webhook_events
WHERE received_at < now() - INTERVAL '30 days';

Pattern 2: Redis SET NX with TTL

If you're already running Redis, SET ... NX EX gives you an atomic claim with automatic expiry in one command:

const key = `wh:stripe:${event.id}`;

// Atomic "set if not exists" with a 7-day TTL.
// Returns null if the key already existed.
const isFirstDelivery = await redis.set(key, "1", {
  NX: true,
  EX: 60 * 60 * 24 * 7,
});

if (!isFirstDelivery) {
  return res.status(200).send("ok"); // duplicate
}

await processEvent(event);
return res.status(200).send("ok");

Postgres trade-offs

  • • Durable — dedup state survives restarts and failovers
  • • Can live in the same transaction as your side effects (the strongest guarantee available)
  • • You must prune manually or the table grows forever
  • • Adds a write to your primary database on every webhook — fine for most volumes, worth measuring at thousands per second

Redis trade-offs

  • • Fast, and TTL cleanup is built in
  • • Works across distributed handlers — every instance checks the same store
  • • Weaker durability: an eviction under memory pressure or a failover can lose dedup state, letting a duplicate through
  • • The claim and your side effects can't share a transaction — if processing crashes after the claim, the event is marked done but never processed

The crash-after-claim problem

Both patterns share a subtle failure mode: you claim the key, then your process dies before finishing the work. The retry arrives, sees the key, and skips the event — which is now lost. The robust fix is to store a status with the key (processing vs done) and let retries re-attempt events stuck in processing past a deadline. With Postgres you can sidestep it entirely by doing the insert and the side effects in one transaction: if processing fails, the claim rolls back too.

Dedup at the Door vs Idempotent Side Effects

Everything above is “dedup at the door”: reject duplicates before processing starts. The complementary strategy is making the processing itself idempotent, so that running it twice produces the same end state. The strongest systems do both — the door check handles the common case cheaply, and idempotent side effects catch whatever slips through (an expired TTL, a lost Redis key, a replay from six months later).

A pattern you'll see in a lot of codebases — and should not copy — is check-then-act:

// DON'T do this: race condition between the check and the insert
const existing = await db.query(
  "SELECT 1 FROM webhook_events WHERE event_id = $1",
  [event.id]
);

if (existing.rowCount > 0) {
  return res.status(200).send("ok");
}

// Two concurrent deliveries of the same event can BOTH reach
// this line, because neither saw the other's uncommitted insert.
await db.query("INSERT INTO webhook_events (event_id) VALUES ($1)", [
  event.id,
]);
await processEvent(event); // ...and both process the event.

This isn't theoretical. Providers retry on a timer, your handler runs on multiple instances behind a load balancer, and a retry can land while the original delivery is still in-flight. Two requests run the SELECT at the same moment, both see zero rows, both proceed. INSERT ... ON CONFLICT DO NOTHING wins because the database serializes the conflict: exactly one insert succeeds, and the loser finds out atomically via rowCount. The same logic applies to Redis: GET then SET is a race, SET NX is not.

Idempotent side effects extend the same thinking into your domain logic:

Upserts instead of inserts. INSERT ... ON CONFLICT (order_id) DO UPDATE converges to the same row no matter how many times it runs.

Absolute states instead of deltas. Set status = 'paid' rather than incrementing a balance. Setting a state twice is harmless; applying a delta twice is corruption.

Pass idempotency keys downstream. If the webhook triggers a payment or an email through another API, forward your event ID as that API's idempotency key so the whole chain dedupes consistently.

Testing Duplicate Handling With Hooklistener

Dedup logic is easy to write and easy to get subtly wrong, and the failure only shows up when a duplicate actually arrives. You shouldn't wait for production to find out. The test you want is simple: take one real event and deliver it to your handler several times.

Capture once, replay repeatedly

Point your provider at a Hooklistener endpoint (or run a CLI tunnel in front of your local server) and trigger one real event. Once it's captured, replay it against your handler as many times as you like — same headers, same body, same signature. The sequence to verify:

  • Replay 1: handler processes the event and responds 200
  • Replays 2–5: handler short-circuits as a duplicate, still responds 200, and responds noticeably faster (no processing happened)
  • Side effects:exactly one row, one email, one charge — check your database, not just the status codes

Replay also lets you modify the body and headers before sending. Change the event ID to a fresh value and replay again: your handler should treat it as a brand-new event. That two-sided test — same key dedupes, new key processes — catches the embarrassing failure mode where your dedup accidentally swallows everything.

Read the responses, not just the requests

Hooklistener records the HTTP response for each captured webhook — status code, headers, body (up to 10 KB), and whether it came from the endpoint's default response or a matched mock rule — in a Response tab on each request. The request list has a color-coded status column (green 2xx, blue 3xx, amber 4xx, red 5xx), so a dedup test reads at a glance: five identical requests, five green responses. If replay three is amber or red, your duplicate path is throwing instead of acknowledging — which means the provider would keep retrying forever. On paid plans you can also select two to five captured requests and run an AI comparison that breaks down shared patterns, differences, timing, and anomalies across them — useful when you're staring at near-identical retries trying to spot what changed.

Generate real provider retries

Replays test your logic; real retries test the provider's actual behavior — headers, timing, whether the payload is byte-identical across attempts. Configure a Hooklistener endpoint to return a custom 500 response, point Stripe or Shopify at it, and trigger an event. The provider sees the failure and begins its retry schedule, and every retry lands in your webhook inbox where you can confirm the event ID really is stable across attempts before you bet your dedup strategy on it.

Lightweight dedup in Automations with the Datastore

If you're using Hooklistener Automations to forward or transform webhooks (both paid features), you can dedup inside the chain itself using the Datastore — an organization-wide persistent key-value store with namespaced keys, JSON values up to 64 KB, and optional TTLs with automatic cleanup. Each endpoint's Automate tab runs an ordered chain of actions on every captured webhook, so a dedup chain looks like this:

  • Extract JSONpulls the provider event ID from the payload — e.g. $request.body.id$for Stripe — into a variable
  • Condition checks whether a Datastore key for that event ID already has a value (read via $store.namespace.key$ template variables) and stops the chain if it does
  • Store Variable writes the event ID to the Datastore with a 24-hour TTL, so the key expires on its own after the retry window
  • HTTP Request forwards the now-known-unique event to your real handler

Duplicates hit the Condition, stop, and never reach your downstream system — no Redis, no schema migration. The per-request run trace shows each step's phase and status, so you can watch a duplicate get stopped mid-chain. The Datastore page in the app sidebar lets you browse keys by prefix and namespace, and the same data is reachable via the REST API and MCP tools if you want your AI assistant to inspect dedup state. For more on what automation chains can do, see running custom JavaScript on every webhook and conditional webhook routing. Plan details are on the pricing page.

To be clear about the boundary: Datastore dedup is a great fit for automation pipelines, internal tooling, and keeping noisy duplicates away from downstream systems. For money-touching production handlers, keep the dedup check in your own infrastructure where it can share a transaction with your side effects — and use Hooklistener to prove it works before you ship.

Idempotency Checklist

  • Assume duplicates.At-least-once delivery means they're part of the contract, not a provider bug.
  • Use the provider's IDas your idempotency key — Stripe's evt_ID, GitHub's X-GitHub-Delivery GUID, Shopify's X-Shopify-Webhook-Id. Hash the raw body only when no ID exists.
  • Scope keys by source(and environment) and give them a TTL longer than the provider's retry window.
  • Make the claim atomic: INSERT ... ON CONFLICT DO NOTHING or Redis SET NX EX. Never check-then-act.
  • Return 200 for duplicates.A duplicate is a success from the provider's perspective; an error response just schedules more duplicates.
  • Make side effects idempotent too — upserts, absolute states, idempotency keys passed downstream — so anything that slips past the door is still harmless.
  • Handle the crash-after-claim case with a processing/done status or by putting the claim in the same transaction as the work.
  • Test it: capture a real event, replay it five times, and verify one set of side effects and five green responses. Then change the event ID and confirm it processes as new.
  • Verify signatures first.Dedup state should only be written for authentic events — see HMAC verification best practices.

Prove Your Dedup Works Before Production Does

The difference between “we handle duplicates” and “we tested duplicate handling” is one replayed webhook. Capture a real event with Hooklistener, replay it against your handler until the responses tell the right story, and ship with evidence instead of hope.

Start Replaying Webhooks Free →

Related Resources