Idempotent Webhooks: How to Prevent Duplicate CRM Leads
Learn the practical database-first pattern for handling repeated webhook deliveries safely without losing real events.
Why a webhook can arrive twice
A sender cannot always tell whether the receiver completed a request. Imagine a CRM creates a contact, but the response is lost during a network timeout. The sender retries. Without a stable event identifier and deduplication, the CRM creates a second contact even though the original request succeeded.
Retries, replays and event ordering are normal operational concerns for webhook consumers. Treat delivery as potentially at least once; exactly-once business effects must be enforced on your side.
Use an event ID, not an email address
Two different people can share a mailbox, and one person can submit a form twice for legitimate reasons. A duplicate delivery is not the same thing as a duplicate customer. Deduplicate the delivery using a stable event ID scoped to the sender and destination.
A durable database uniqueness constraint handles concurrent requests more reliably than checking an in-memory Set. The critical step is to record the event and the intended business change in the same transaction, or maintain a recoverable processing state.
| Candidate key | Problem | Use |
|---|---|---|
| Email address | Different events may share it | Business matching only |
| Request timestamp | Retry changes time | Observability |
| Sender + event ID | Stable across redelivery | Delivery idempotency |
| Lead identifier | May group several event types | Business correlation |
Database-first pseudocode
This example assumes a unique key on sender and event ID. The business mutation must participate in the same transaction; the snippet is illustrative and not tied to a particular ORM.
async function handleWebhook(db, event) {
await db.transaction(async (tx) => {
// Unique key: (sender, eventId).
const inserted = await tx.inbox.insertIfAbsent({
sender: event.sender, eventId: event.id,
});
if (!inserted) return; // Already processed.
await tx.leads.applyEvent(event);
});
return { accepted: true };
}Test the awkward scenarios
- Send exactly the same valid event twice, including concurrently.
- Simulate a response timeout after committing the database transaction.
- Reject invalid signatures before trusting event IDs or payloads.
- Decide how to recover incomplete transactions and poisoned events.
- Distinguish repeated delivery from a genuine second form submission.
