Integration · Webhooks

Webhook Idempotency — A Field Guide

Webhook delivery is at-least-once, which means the same event will eventually arrive twice. An idempotent consumer is the only thing standing between that and a double charge.

John Kihiu12 min read

Every serious webhook provider delivers at-least-once: if they do not get a timely 2xx back, they retry, and networks being what they are, some of those retries arrive after you already processed the original. If handling an event twice charges a card twice or ships an order twice, that is not a rare edge case — it is a certainty on a long enough timeline. Idempotency is how you make duplicate delivery harmless.

Dedupe on a stable event id

Good providers put a unique, stable identifier on every event — the same id on every retry of that event. That id is your dedupe key. Before doing any work, record the id in a store with a uniqueness constraint; if it is already there, you have seen this event and can acknowledge without reprocessing.

SQL · dedupe with a unique constraint
CREATE TABLE processed_events (
  event_id   TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- In the handler, inside a transaction:
INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING;
-- If no row was inserted, this is a duplicate -> ack and stop.

Do the insert and the side effect in the same transaction where possible, so you never end up with the event marked processed but the work rolled back, or the work committed but the event not recorded. If the side effect is in an external system, record the event id atomically with whatever local state proves the work happened.

Make the effect naturally safe to repeat

Dedupe tables are the general answer, but the strongest designs make the operation itself idempotent so a repeat is a no-op. Prefer upserts keyed on the business entity over blind inserts. Use the provider's own idempotency key when you call downstream APIs, so your retries do not create duplicates one layer further out. "Set status to shipped" is safe to run twice; "increment inventory by one" is not.

No stable id? Derive one.

Some providers do not send a reliable event id. Derive a deterministic key from immutable fields of the payload — a hash of the resource id plus the event type plus a timestamp — so the same logical event hashes the same way on every retry. A dedupe key you compute is far better than none.

Idempotency is not an optimisation you add after the first double-charge incident; it is the baseline contract of consuming webhooks. Record the event id, guard the write, make the effect repeatable, and at-least-once delivery quietly becomes exactly-once processing.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.