Webhook Idempotency and Deduplication (2026): Stop Processing Events Twice

Updated September 26, 2026Published June 10, 202615 min read

Webhook idempotency means processing each event exactly once even though providers deliver it at least once: store a stable key such as the provider's event ID, claim it atomically with INSERT ... ON CONFLICT DO NOTHING (Postgres) or SET NX (Redis), skip the work when the claim fails, and still return 200.

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.

Key takeaways

  • Duplicates are part of the contract. Stripe, GitHub and Shopify all deliver at least once, so the same event will reach your handler more than once.
  • Key on the provider's ID. Stripe evt_ ID, GitHub X-GitHub-Delivery, Shopify X-Shopify-Webhook-Id; hash the raw body only when no ID exists.
  • Claim atomically, never check-then-act. Use INSERT ... ON CONFLICT DO NOTHING in Postgres or SET key 1 NX EX in Redis; a SELECT followed by an INSERT races.
  • Put the claim and the side effects in one transaction. If processing fails, the key is released and the provider's retry can succeed.
  • Return 200 for duplicates. An error response only schedules more retries of an event you already handled.
  • Test dedup with real replays. Capture one event on a Hooklistener endpoint, replay it five times, and save it as a replay case so every handler change reruns the duplicate check.

Why do webhooks arrive more than once?

Webhooks arrive more than once because providers retry any delivery they cannot confirm, and slow responses, lost acknowledgments, provider redeliveries and manual resends all look unconfirmed. 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 times out the whole request after 5 seconds, GitHub asks for a 2xx within 10 seconds, and Stripe publishes no exact number but tells you to respond before any complex logic — 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 log processed event IDs. It also notes that, in some cases, two separate Event objects are generated for one change; to catch those, dedupe on data.object.id plus event.type as well.

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.

Which ID do Stripe, GitHub and Shopify send for deduplication?

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 (sandbox events: three times over a few hours), and the evt_ ID never changes. Dedup on it.

One source of confusion: Stripe also has Idempotency-Key headers for API requests you send 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 not automatically 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 identifies each delivery, and Shopify's own docs tell you to use it to detect and skip duplicates. A separate X-Shopify-Event-Id is shared by every delivery that came from the same merchant action (for example, across several subscriptions), so use it for correlation rather than per-delivery dedup. Shopify times out requests after 5 seconds and retries 8 times over the next 4 hours; subscriptions created through the Admin API are deleted after 8 consecutive failures. A handler that's slow during a flash sale will see a wall of duplicates. 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.

What should you use as a webhook idempotency key?

Use the provider's event or delivery ID, prefixed with the source, and fall back to a SHA-256 of the raw body only when no ID exists. 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 and resend windows: Stripe retries for up to 3 days and lets you resend an event from the CLI for up to 30 days; Shopify retries over 4 hours. A TTL that covers those windows plus a margin (say, 30 days for Stripe, 7 days for Shopify) bounds your storage while covering every automatic retry and most manual resends. 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.

How do you deduplicate webhooks in Postgres and Redis?

Claim the key with a single atomic write — a primary-key insert with ON CONFLICT DO NOTHING in Postgres or SET ... NX EX in Redis — and only process the event when the write succeeded. 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.

Transactional dedup in Node.js and Python

The claim and the database side effects commit or roll back together. A concurrent duplicate's INSERT waits on the first transaction's row lock: if the first commits, the duplicate inserts nothing and skips; if it rolls back, the duplicate claims the key and does the work.

node-postgres (pg)
const client = await pool.connect();
try {
  await client.query("BEGIN");
  const { rowCount } = await client.query(
    `INSERT INTO webhook_events (event_id, source)
     VALUES ($1, 'stripe')
     ON CONFLICT (event_id) DO NOTHING`,
    [`stripe:${event.id}`]
  );
  if (rowCount === 1) {
    await applySideEffects(client, event); // same transaction
  }
  await client.query("COMMIT");
  res.status(200).send("ok"); // first delivery or duplicate
} catch (err) {
  await client.query("ROLLBACK"); // releases the key
  res.status(500).send("retry"); // provider will redeliver
} finally {
  client.release();
}
psycopg 3
def handle_event(conn, event: dict) -> None:
    with conn.transaction():  # commit on success, rollback on exception
        cur = conn.execute(
            """INSERT INTO webhook_events (event_id, source)
               VALUES (%s, 'stripe')
               ON CONFLICT (event_id) DO NOTHING""",
            (f"stripe:{event['id']}",),
        )
        if cur.rowcount == 0:
            return  # duplicate: already processed
        apply_side_effects(conn, event)  # same transaction

Emails and third-party API calls cannot roll back. Write them to an outbox table inside the same transaction and send them from a worker, passing your event ID as the downstream idempotency key.

Is deduplication enough, or do side effects need to be idempotent too?

You want both. 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.

How do you test webhook duplicate handling? (Test it with Hooklistener)

Deliver one real event to your handler several times and check that it was processed once. Dedup logic is easy to write and easy to get subtly wrong, and the failure only shows up when a duplicate actually arrives. With Hooklistener the whole test takes four steps, from the dashboard or from your AI assistant through the Hooklistener MCP server:

  1. Capture one real event. Point Stripe, GitHub or Shopify at a Hooklistener endpoint and trigger an event (for Stripe, stripe trigger payment_intent.succeeded).
  2. Replay it repeatedly. Run hooklistener listen --endpoint <ENDPOINT_ID> --port 3000 and call replay_request with target: "cli" five times (each call takes its own idempotency key). Add signing_provider and a stored signing_secret_id to re-sign each replay so signature checks pass.
  3. Save the regression cases. Use save_request_case with an assertion on status 200, then save a second case whose body has a fresh event ID.
  4. Rerun on every change. Call run_endpoint_cases against localhost or staging and read the pass/fail report with wait_for_case_run: the duplicate must return 200 without new side effects, the new ID must be processed.

The sections below go deeper on each step.

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.

Group duplicates by event ID with request threads

Once real retries are flowing, you want to see them side by side. A thread rule on the endpoint correlates captured requests by a field you choose — e.g. body.id for Stripe — so every delivery of the same event lands in one thread. From there:

  • • Count the attempts the provider actually made for one event, and how far apart they were
  • • Diff two deliveries with the deterministic request diff to confirm the body is byte-identical and only delivery headers (timestamps, signatures, attempt counters) changed
  • • Check the responses your endpoint returned for each attempt, so you can see which failure kept the retries coming

Lock the behavior in with a replay suite

A dedup check that works today can regress with the next refactor. Save the captured event as a replay case with an assertion on the response status, add it to a suite alongside a copy with a fresh event ID, and rerun the suite whenever the handler changes: the duplicate must still return 200, and the new ID must still be processed. The same cases are reachable through the MCP tools if you want your AI assistant to run them for you. For fanning one captured event out to several handlers at once, see sending webhooks to multiple endpoints. Plan details are on the pricing page.

To be clear about the boundary: Hooklistener captures, groups, and replays duplicates — it doesn't dedup them for you. 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 ID as 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. The Free plan includes one endpoint and three saved replay cases.

Frequently Asked Questions

How do I make a webhook handler idempotent?

Pick a stable idempotency key (the provider's event or delivery ID, such as Stripe's evt_ ID), claim it atomically before doing any work with INSERT ... ON CONFLICT DO NOTHING in Postgres or SET key NX EX in Redis, skip the event when the claim fails, and still return 200 for duplicates. Make side effects idempotent too (upserts, absolute states, downstream idempotency keys) so anything that slips past the check is harmless.

How do I deduplicate webhooks in Postgres from Node.js or Python?

Create a table with the idempotency key as PRIMARY KEY. In one transaction, run INSERT INTO webhook_events (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING, check the affected row count (rowCount in node-postgres, cursor.rowcount in psycopg), apply the side effects only when it is 1, then commit. A concurrent duplicate waits on the first transaction and then inserts nothing; if processing fails, the rollback releases the key so the provider's retry can process it.

Which ID should I use to deduplicate Stripe, GitHub and Shopify webhooks?

Stripe: the event id (evt_...) in the body, which is the same on every retry. GitHub: the X-GitHub-Delivery header, which stays the same when a delivery is redelivered. Shopify: the X-Shopify-Webhook-Id header for individual deliveries (X-Shopify-Event-Id correlates deliveries of the same event across subscriptions). Prefix the key with the source, for example stripe:evt_123.

How long should I keep webhook idempotency keys?

Longer than the provider's retry window plus any manual replay window. Stripe retries live-mode events for up to three days and allows CLI resends for 30 days; Shopify retries 8 times over 4 hours; GitHub does not retry automatically. Keeping keys for 30 days covers most cases, and idempotent side effects cover anything older.

How can I test that my webhook handler ignores duplicates?

Capture one real event on a Hooklistener endpoint and replay it to your handler several times: the first replay should process it, every later one should return 200 with no new side effects. Save it with the save_request_case MCP tool, add a copy with a new event ID, and rerun both with run_endpoint_cases after every change so the dedupe path cannot silently regress.

Related Resources