Skip to main content
A webhook handler will receive duplicate deliveries of the same event. This is not a bug in your code or a bug in Virtuous — it is a structural property of any reliable webhook system, and any handler that doesn’t anticipate it will eventually produce double-counted gifts, duplicate emails, or corrupted donor records. This page covers why duplicates happen, why ordinary database writes are not enough to defend against them, and the idempotency patterns that make a handler safe to re-run.

Why duplicates happen

Four scenarios produce duplicate webhook deliveries to your endpoint: The first three are common and unavoidable. The fourth is rare but possible. All four produce the same observable behavior on your end: the same event ID arriving more than once.
Receiving a duplicate is the normal case, not the exceptional one. Plan for every event to be delivered at least twice over the lifetime of your integration, even on a perfectly healthy system. A handler that produces double-counted side effects on a duplicate delivery is not “robust under unusual conditions” — it is unreliable under normal conditions.

Why ordinary database writes are not enough

A common first attempt at idempotency relies on database uniqueness — “I’ll just upsert the record, so a second delivery is a no-op.” This works for some cases but fails in others, depending on what side effects the handler produces. The pattern: idempotency through database state works for “make the row look like this” operations and fails for “do this thing once” operations. Most real partner integrations have at least some “do this thing once” side effects — sending a notification, posting to an accounting system, triggering an automation. Those need an explicit idempotency mechanism.

The idempotency-key pattern

The canonical solution is to track which events you have already processed. Before processing an event, check whether you have seen its identifier; if you have, skip processing. If you have not, process the event and then record the identifier.
JavaScript
Three states for each event ID: This three-state pattern matters because of the concurrent-delivery edge case: two deliveries of the same event arriving in parallel. With only two states (“seen” / “unseen”), both deliveries see “unseen” simultaneously, both process the event, and you get double side effects. With three states, the first delivery atomically transitions unclaimed → claimed, the second sees claimed and skips.

Implementation choices

The idempotency store needs to support an atomic compare-and-set operation. Several options work: Redis with SET ... NX:
JavaScript
PostgreSQL with INSERT ... ON CONFLICT DO NOTHING:
If INSERT affected zero rows, the event was already claimed. A managed queue with deduplication: AWS SQS FIFO queues support content-based deduplication via a MessageDeduplicationId. Pass the Virtuous event ID as the dedup ID; SQS drops duplicate enqueue attempts within the dedup window. Choose based on the durability and consistency requirements of your handler. For most partner integrations, a relational database table is the simplest, most observable option. Redis is faster but requires careful TTL management. Managed queue dedup is convenient but the dedup window is finite (5 minutes on SQS) which is not long enough to defend against late retries.

Retention

Keep idempotency records for at least the maximum retry window. A safe default is 30 days — long enough to outlast the longest realistic retry sequence and to defend against replay attacks within a reasonable window. For very high-volume integrations where storage cost matters, 7 days is also defensible if you have good observability around retry-driven duplicates.

Side-effect ordering

A subtler pitfall: the order in which you mark the event as completed and perform side effects matters. Consider this code:
JavaScript
If the process crashes between the two lines, the next delivery will re-run both — sending a second email. Reversing the order doesn’t help either:
JavaScript
Now if the process crashes between the lines, the next delivery sees completed and skips — the email is never sent. The robust pattern is to perform side effects through the same atomic mechanism as the completion record. Two options: Option A: Transactional outbox. Write the side-effect intent to a database table in the same transaction that marks the event complete. A separate worker reads the outbox and actually performs the side effects, marking them done after success.
JavaScript
Option B: Idempotency keys on downstream APIs. Many downstream services accept an idempotency key (Stripe, SendGrid, Mailchimp, Twilio). Derive the key from the Virtuous event ID and the side-effect type — ${eventId}-email for the email, ${eventId}-stripe-charge for the charge. The downstream service handles dedup.
JavaScript
For partner integrations with multiple side effects per event, the transactional outbox pattern is the most reliable.

Handling out-of-order deliveries

Webhooks can arrive out of chronological order — particularly after a retry sequence. A contact.updated event delivered in the first attempt may arrive after a later contact.updated event that succeeded on its first try. For most integrations, the right defense is to use the timestamp in the event payload (not the time you received it) for ordering decisions. When processing a Contact update, compare the event’s timestamp against the modifiedDateTimeUtc already stored for the Contact in your database:
JavaScript
This pattern is naturally idempotent on its own — a re-delivery of the same event will see the stored modifiedDateTimeUtc matches and skip the update. But it works only for state-update events, not for side-effect events. Use it alongside the idempotency-key pattern, not as a replacement.
If your handler does anything beyond updating a row in your database — sending an email, posting to a downstream API, charging a card — use both the timestamp check and the idempotency-key store. The timestamp catches stale state-updates; the idempotency key catches duplicate side effects.

Cross-source duplicates

A specifically partner-relevant duplicate scenario: your integration both submits Transactions and subscribes to webhooks. When you submit a Gift Transaction via POST /api/v2/Gift/Transaction, the nightly batch eventually creates the real Gift — and fires a giftCreate webhook back to your endpoint. If your handler also creates a record for the Gift on your side, you can end up with two records of the same gift unless you deduplicate. The defense is the transactionSource / transactionId pair you sent on the original submission. The webhook payload carries the same pair back to you — match against it before creating a new record on your side:
JavaScript
This pattern matters most for Gifts but applies anywhere your integration writes records that may also generate webhook events. See Transactions for the upstream side of this pattern.

Testing idempotency

Build a test fixture that replays the same event twice through your handler. The handler’s observable behavior — database rows, downstream API calls, emails, audit log entries — should be identical to a single delivery:
JavaScript
Run this test for every event handler. The most common partner-integration bug found this way is the missing idempotency key on a side effect — typically an email or a downstream API call.

Where to go next

Retry Behavior

The retry mechanism that produces most of the duplicate deliveries your idempotency layer needs to handle.

Signature Verification

The replay-protection layer that complements idempotency for security-sensitive scenarios.

Transactions

The cross-source duplicate scenario where your own submissions generate webhooks you also receive.

Local Testing

Test idempotency by replaying captured fixtures through your local handler.
Last modified on May 21, 2026