← All articles

Idempotency Keys for Webhook Consumers

Webhooker Team Updated 11 min read
Flat illustration of idempotency keys: a row of slots where one message settles into the last free slot as a blue pulse card while its duplicate is deflected away.

An idempotency key is a stable, unique id your webhook consumer assigns to each event, usually the provider’s own event id, so that handling the same event twice produces the same result as handling it once. You record the keys you have already processed and skip any repeat, which turns a duplicate delivery into a safe no-op instead of a second charge, a second email, or a double-counted order.

A handler that assumes every event arrives exactly once will eventually do the wrong thing. Duplicates come with reliable delivery, and no patch removes them. Exactly-once delivery does not survive an HTTP boundary, so webhook idempotency has to live in your code. An idempotent webhook handler makes repeats harmless.

Why you get the same event twice

Duplicates are a side effect of the delivery system doing its job.

The most common cause is a retry after a write you already committed. Your handler receives an event and updates the database, and then the response back to the sender gets lost, or your server takes too long to reply. The acknowledgement half of the exchange is where duplicates are born. From the sender’s point of view the delivery failed, so it sends the event again, and you are asked to do work you already did. That timeout-after-success window is why retries make duplicates unavoidable, and no amount of tuning removes it.

Providers redeliver for their own reasons too. Stripe retries failed events for up to three days in live mode and lets you manually resend from the dashboard on top of that, and the two overlap more often than you would expect, as the Stripe webhooks guide explains. GitHub lets you redeliver any webhook from the past three days from the settings UI. A load balancer hiccup, a dropped connection, a deploy that restarts a pod mid-request: each one can produce a second copy of an event you have already seen.

Gateways add their own retries. Webhooker sits in front of your endpoint as an inbound gateway and delivers to your consumer at least once, with a full per-attempt history, because handing you an event twice is recoverable and dropping it is not. Your handler has to expect repeats either way, and every delivery carries an X-Webhooker-Event-Id that stays the same across retries and replays, so there is always a key to dedupe on. What a webhook gateway guarantees covers where at-least-once comes from and why exactly-once claims don’t hold up.

What an idempotency key actually is

The key is whatever value stays identical across every copy of the same logical event, no matter how many attempts it takes to reach you.

A good key identifies the event, not the delivery attempt. If Stripe sends evt_1P9x... three times, all three carry the same event id, and that id is your key. Never key on anything that changes per attempt: a timestamp at receipt, a random request id your framework generates, the retry count. Each retry would then look brand new and get processed again.

Most of the time you get the key straight from the payload. Sometimes you build it from a few stable fields. Either way, derive it from the event’s identity and never from the moment it happened to arrive.

Two things share this name, and they point opposite ways

Search for “idempotency key” and most of what you find describes a different mechanism, so separate the two before going further.

The other one is the Idempotency-Key request header. There, you are the client calling somebody’s API. You generate a key, usually a UUIDv4, and send it with a POST so that a retry after a timeout does not create a second charge. The server stores your key with the response it produced, and replays that stored response if the same key shows up again. Stripe’s API works this way, Square’s does, and there is an IETF draft for the header, though MDN still lists it as non-standard.

This article is about the other direction. Nobody hands you a key. The event arrives with an id the provider assigned, and you decide whether you have seen it before. Here you are the server, and you read the key instead of generating it.

Idempotency-Key headerWebhook event id
Who creates the keyYou, the callerThe provider
DirectionYour request going outTheir event coming in
Where it livesA request header you setThe payload or a header you read
On a repeatServer replays its stored responseYou skip the work and return 200

Both exist because retries are unavoidable and a retry must not double the side effect. A service that calls APIs and receives webhooks ends up implementing both, and mixing them up is how people build a dedupe store keyed on a value that changes every time. The rest of this page covers the inbound half.

The dedupe store: check and act in one step

You need somewhere to remember which keys you have already handled. A small table works well:

CREATE TABLE processed_events (
    event_key   TEXT PRIMARY KEY,
    source      TEXT NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The primary key on event_key does the heavy lifting: the database refuses a second row with the same key.

Here is the version most people write first. It looks reasonable and it is broken:

# BROKEN: race condition between the check and the write
if not db.exists("processed_events", event_key):
    handle_event(event)          # charge, email, whatever
    db.insert("processed_events", event_key)

Under load, two copies of the same event can run this at the same time. Both check, both find nothing, and both process the event. The duplicate slips through the gap between reading and writing.

The fix is to let the database decide, atomically, whether this is the first time you have seen the key:

# Atomic claim. Only one caller wins the insert.
inserted = db.execute(
    "INSERT INTO processed_events (event_key, source) "
    "VALUES (%s, %s) ON CONFLICT (event_key) DO NOTHING "
    "RETURNING event_key",
    [event_key, source],
)

if not inserted:
    return 200          # already handled, this copy is a no-op

handle_event(event)

One statement claims the key. If the row already exists, ON CONFLICT DO NOTHING returns nothing and you stop. Only the caller that actually inserted the row goes on to process the event. There is no window between checking and acting because there is no separate check.

If you prefer Redis, SET key value NX EX 604800 gives you the same atomic claim with a built-in expiry. The tradeoff is durability: Redis without persistence can lose keys on restart, and a lost key means a duplicate gets reprocessed. For anything touching money, keep the record of truth in your database.

One caveat on ordering. Claim the key first, then process. If processing can fail, the claim and the work need to commit together, or you need a way to release the key on failure. Otherwise a crash between the claim and the work leaves a key marked done for an event that never ran. Wrapping both in one transaction is the cleanest option.

Idempotent writes make the no-op free

Deduplication in a side table is the first defense. The second is making the writes themselves idempotent, so a duplicate that gets past the store still does no damage.

Three patterns cover most cases. Use a unique constraint on a natural business key, so a second insert of the same order simply fails instead of creating a twin:

ALTER TABLE orders ADD CONSTRAINT uq_orders_event
    UNIQUE (provider_event_id);

Use an upsert when you want the event to set a final state rather than accumulate:

INSERT INTO invoices (id, status, amount)
VALUES (%s, 'paid', %s)
ON CONFLICT (id) DO UPDATE SET status = 'paid';

And use a conditional update when only a specific transition is valid, so replaying a payment.succeeded on an already-paid invoice changes nothing:

UPDATE invoices SET status = 'paid'
WHERE id = %s AND status = 'pending';

In all three, running the write once or five times leaves the same state behind. With writes like that, a duplicate is boring.

Where the key comes from

For Stripe, use the event id on the event object, the evt_ value in id. It is stable across redeliveries and is the value Stripe itself recommends logging to guard against duplicates. Do not use the object id inside data (like a ch_ charge id), because one object can generate several distinct events. The reverse also happens: one Checkout payment produces both checkout.session.completed and payment_intent.succeeded, two events with two ids for the same order, which is why fulfilment should hang off one of them and be keyed by the Checkout Session id.

For GitHub, use the X-GitHub-Delivery header, a globally unique id GitHub attaches to each webhook. A manual redelivery from the GitHub UI reuses the same delivery id, which is the behavior you want for dedupe. Verifying GitHub webhooks covers that header alongside the signature check.

The same id has a second use. Besides deduping ordinary retries, it is the other half of replay protection: a timestamp window rejects stale captured requests, and the delivery id catches anything resent inside the window. Why a valid signature is not enough works through that pairing.

For other providers, check their docs for a documented event id before you invent one. Sweego, for example, puts a per-event UUID in event_id and a per-message UUID in transaction_id, which are two different keys for two different jobs: dedupe on the first, group a message’s history on the second. If a provider has no event id at all, build a key from stable fields in the payload, for example a hash of resource_id plus event_type plus the provider’s own timestamp. Avoid anything you generate at receipt.

Whichever source you use, write the key into your logs next to every delivery attempt. When a duplicate does cause trouble, you can then pull every delivery that shared the key and see which copy did the work, which one was skipped, and whether the claim and the side effect committed together. A key that only exists in the dedupe table tells you that something ran, but not when or how many times it was attempted.

Pitfalls we see most often

The non-atomic check-then-insert from earlier is number one. It passes every test you run at low volume and only breaks under concurrency, usually in production during a traffic spike. Always claim the key in a single statement.

Second, keys that change between copies. Someone keys on a receipt timestamp or a per-request UUID, and every retry reads as a new event. Test this on purpose: replay the same event and confirm the second run does nothing.

Third, a TTL shorter than your retry window. If you expire keys after an hour but your gateway can retry for a day, a late retry arrives after the key is gone and gets reprocessed. Keep keys longer than the longest possible retry span, with some margin; the extra rows cost very little.

Frequently asked questions

How long should I keep idempotency keys?

Longer than your longest possible retry window, plus a safety margin. If your gateway can retry for up to 24 hours, keeping keys for a week is reasonable and cheap. The failure mode you are avoiding is a late retry landing after its key has expired and getting processed as new. When in doubt, keep keys longer; a slightly larger table costs far less than a double charge.

Is this the same as the Idempotency-Key header?

No, they point in opposite directions. The Idempotency-Key header is something you generate and send when calling someone else’s API, so their server can recognise your retry and replay its original response. A webhook idempotency key is something you read from an event arriving at your server, so you can recognise the provider’s retry and skip work you already did. Same underlying problem, opposite roles: in one you are the client, in the other you are the server.

How is idempotency different from ordering?

They solve different problems. Idempotency makes sure an event is applied once no matter how many times it arrives. Ordering makes sure events are applied in the right sequence. A handler can be perfectly idempotent and still act on a updated event before the created one. Handle ordering separately, usually with a version number or timestamp on the resource and a conditional update that ignores stale events. That check, and the cases that do need ordered webhook delivery, have their own guide.