Panther Hook Blog
Integrations & Automation

Idempotent Webhooks: How to Prevent Duplicate CRM Leads

Learn the practical database-first pattern for handling repeated webhook deliveries safely without losing real events.

AT-LEAST-ONCE INPUTEvent 1Retry 1Unique ID1 writeONE BUSINESS EFFECT

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.

What to deduplicate
Candidate keyProblemUse
Email addressDifferent events may share itBusiness matching only
Request timestampRetry changes timeObservability
Sender + event IDStable across redeliveryDelivery idempotency
Lead identifierMay group several event typesBusiness 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.

javascript
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.
Read about webhook delivery and retries