Webhook Idempotency
Because the same event can arrive twice, your processing must produce the same result no matter how often it runs.
What you'll learn
- Define idempotency in the context of webhook processing
- Use event identifiers to detect and skip duplicates
- Protect side effects that are not naturally idempotent
- Choose a storage strategy for processed-event tracking
6 min read
What idempotency means here
Idempotency is the property that performing an operation many times has exactly the same effect as performing it once. For webhooks it is not a nicety but a necessity, because providers retry and will, sooner or later, deliver the same event more than once. If your processing is idempotent, a duplicate is simply ignored and no harm is done; if it is not, every duplicate can re-apply changes that should only ever have happened a single time.
Some operations are naturally idempotent — setting a field to a fixed value, for example, can be repeated freely with no ill effect. Others most certainly are not: incrementing a counter, charging a card, or sending a confirmation email all cause real damage if they run twice. Your job is to make the whole handler behave idempotently regardless of which kind of operation it performs internally.
Deduplicate by event id
Most providers stamp each event with a unique, stable id that stays the same across every retry of that event. The simplest reliable approach is to record every id you have already processed and check it before you act on anything. If you have seen the id before, you stop; if you have not, you process and then record it.
if (seen.has(event.id)) return respond(200);
applyEvent(event);
seen.add(event.id);
return respond(200);Returning 200 for an already-seen id acknowledges the delivery without redoing any of the work behind it. The store of seen ids becomes your single line of defence against duplicates, so it needs to be reliable and reasonably quick to consult.
Guarding unsafe side effects
Deduplication only actually works if the check and the work it guards cannot interleave. If two copies of the same event arrive at almost the same instant, both might pass the have I seen this? check before either one records the id — and then the unsafe side effect fires twice anyway, exactly the outcome you were trying to prevent. To stop this, use a database transaction or a unique constraint on the event id so the second concurrent write fails cleanly.
A unique key is especially neat for this. Insert a row keyed by the event id as part of processing, and a duplicate insertion is simply rejected by the database itself, with no race window at all. That turns idempotency from a hopeful check into a guarantee enforced by storage, which pairs very naturally with the outbox pattern on the producing side.
Where to keep the record
You need somewhere durable to remember which event ids you have processed. A dedicated table is the common choice, and you may safely prune old entries once you are confident the provider will never retry that far back into the past. A fast in-memory cache can sit in front of the table to speed up the common case, but the durable store must remain the authoritative source of truth.
Whatever storage you pick, the golden rule is to make the deduplication check atomic with the work it protects, so concurrency cannot slip a duplicate through. The provider's id field, and crucially how stable those ids are across retries, are described in the API documentation, which tells you exactly what you can rely on.
Key takeaways
- Idempotent processing makes a duplicate delivery a harmless no-op
- Use the provider's stable event id to detect repeats
- A unique constraint or transaction prevents racing duplicates
- Store processed ids durably and keep the check atomic with the work
FAQ
Why can the same event arrive twice?
Retries. If your acknowledgement is lost or late, the provider resends the event even though you already handled it, so duplicates are normal and must be tolerated.
What is the simplest way to deduplicate?
Record each processed event id and skip any you have already seen, returning a 2xx. Back it with a unique database constraint to handle concurrent duplicates safely.
Do I need to keep processed ids forever?
No. You only need them as long as the provider might retry that event. Once its retry window has passed, old ids can be pruned to keep the store small.
Ready to build?
Read the API reference, grab the OpenAPI spec, and ship a resilient integration.