Webhook delivery systems that guarantee at-least-once delivery will sometimes deliver the same event more than once. The network drops a response, a retry fires before the first attempt completes, or a recovery process replays events after an outage. Your consumer ends up seeing the same event twice.
Idempotency is the property that applying an operation multiple times produces the same result as applying it once. For webhook consumers, this means processing the same event a second time should have no additional side effects. A customer shouldn’t get charged twice because a retry landed after the original request was already processed.
How Duplicates Happen
Consider this sequence:
- Your system sends a
payment.completedwebhook to the consumer - The consumer receives it, processes it, and charges the customer
- The consumer’s server starts sending a
200 OKresponse - The network connection drops before the response reaches you
- From your perspective, the delivery failed
- You retry the webhook
- The consumer receives and processes it again
- The customer gets charged twice
This happens regularly in production, especially during periods of network instability or high load. Other common causes include consumer endpoints that take too long to respond (you time out and retry, but the consumer already processed the event), load balancers that automatically retry failed requests before your own retry logic fires, recovery replays from a queue or log where some events were already delivered, and redundant delivery infrastructure that sends the same event through multiple paths.
The Idempotency Key
The foundation of idempotent webhook processing is a unique identifier for each event, typically called the event ID or idempotency key. It’s included in every webhook payload:
{
"id": "evt_a1b2c3d4e5f6",
"event": "payment.completed",
"timestamp": "2026-03-19T14:22:00Z",
"data": {
"payment_id": "pay_8xk2m9",
"amount": 4999
}
}
The id field identifies this specific event occurrence. If the consumer receives two webhooks with the same id, the second one is a duplicate.
A good event ID is globally unique so that no two events ever share an ID. It should be deterministic, meaning the same event always produces the same ID even if resent. Generate the ID when the event occurs, not when the webhook is sent. The ID should also be included in the signed payload so it can’t be tampered with.
Consumer-Side Deduplication
Using a Database
The consumer needs to track which events have already been processed:
def handle_webhook(event):
event_id = event["id"]
if db.execute(
"SELECT 1 FROM processed_events WHERE event_id = %s",
(event_id,)
).fetchone():
return "OK", 200
process_payment(event["data"])
db.execute(
"INSERT INTO processed_events (event_id, processed_at) VALUES (%s, %s)",
(event_id, datetime.utcnow())
)
db.commit()
return "OK", 200
The Race Condition
That code has a subtle bug. Between the SELECT check and the INSERT, another instance of your application could process the same event. This is a time-of-check-to-time-of-use (TOCTOU) race condition.
The fix is to use the database’s uniqueness constraint as the source of truth:
def handle_webhook(event):
event_id = event["id"]
try:
db.execute(
"INSERT INTO processed_events (event_id, processed_at) VALUES (%s, %s)",
(event_id, datetime.utcnow())
)
db.commit()
except UniqueViolationError:
return "OK", 200
process_payment(event["data"])
return "OK", 200
This introduces a different problem: if the insert succeeds and process_payment fails, the event is marked as processed without actually being handled. Wrapping both operations in a transaction fixes this:
def handle_webhook(event):
event_id = event["id"]
try:
with db.transaction():
db.execute(
"INSERT INTO processed_events (event_id, processed_at) VALUES (%s, %s)",
(event_id, datetime.utcnow())
)
process_payment(event["data"])
except UniqueViolationError:
return "OK", 200
return "OK", 200
The insert and the processing now either both succeed or both fail. If processing fails, the event isn’t marked as processed, and the next retry will be handled correctly.
Using Redis for High Throughput
For high-volume webhook processing where a database lookup on every event is too slow, Redis offers a faster alternative:
import redis
r = redis.Redis()
def handle_webhook(event):
event_id = event["id"]
# SET NX is atomic: only one consumer can set the key
# EX adds a 7-day TTL to prevent unbounded growth
if not r.set(f"webhook:{event_id}", "1", nx=True, ex=604800):
return "OK", 200
process_payment(event["data"])
return "OK", 200
The NX flag makes the operation atomic, so only one consumer can successfully claim the key. The EX flag adds an expiration so old entries get cleaned up automatically.
The tradeoff is durability. Redis isn’t durable by default. If it restarts and loses data, you lose your deduplication state. For most webhook use cases this is acceptable since the replay window is typically short.
Designing for Natural Idempotency
Deduplication by event ID is the safety net. Where possible, the stronger approach is to design operations that are inherently safe to repeat.
Some operations are naturally idempotent. Setting a value (“set the order status to shipped”) produces the same result no matter how many times it runs. Upserts are idempotent by design. Deletions are safe to repeat since the second attempt is a no-op.
Other operations aren’t. Incrementing a counter (“add $50 to the balance”) produces the wrong result when applied twice. Sending a notification is annoying if it goes out more than once. Creating a record without deduplication creates duplicates.
For non-idempotent operations, use the event ID pattern described above or restructure the operation itself. “Add $50 to the balance” becomes “set the balance for transaction X to $50.” “Create an invoice” becomes “create an invoice with idempotency key X” and the downstream system handles deduplication.
Sender-Side Responsibilities
If you’re building a webhook sender, include a unique event ID in every payload and use the same ID when retrying a failed delivery. Document this contract with your consumers: events may be delivered more than once, and consumers should use the id field to deduplicate. Generate IDs deterministically based on the event itself rather than the delivery attempt. A new event ID on each retry defeats the entire purpose.
Retention and Cleanup
Your deduplication store grows over time. Match your retention window to your maximum retry window. If retries span 7 days, keep event IDs for at least 7 days, ideally 14 to cover edge cases. Automate cleanup with TTLs in Redis, scheduled jobs to prune database tables, or partitioned tables with automated partition drops.
How Hookbridge Handles This
Hookbridge includes a unique event ID in every webhook delivery, and retries use the same ID so consumer-side deduplication is straightforward. Our SDKs include built-in deduplication helpers, and our documentation covers implementation guidance for consumers who aren’t using an SDK.
Next up: Building a Reliable Webhook Consumer: Best Practices. A practical checklist for the receiving side, covering everything from responding quickly to handling out-of-order delivery.