Why duplicates happen
Four scenarios produce duplicate webhook deliveries to your endpoint:| Scenario | Description |
|---|---|
| Virtuous retries after a timeout | Your handler processed the event successfully but took longer than the delivery timeout to respond. Virtuous treats the delivery as failed and retries — your handler will receive and process the same event again. |
| Network glitches between Virtuous and you | The acknowledgement response is lost in transit. Virtuous never sees the 2xx and retries. |
| Your service crashes mid-acknowledgement | Your handler processed the event, then crashed before responding. Same outcome as a timeout. |
| Replay attacks or accidental re-delivery | A captured webhook delivery is replayed by an attacker or by a misbehaving proxy. Signature verification confirms authenticity but not freshness — see Signature Verification. |
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.| Operation | Idempotent via database uniqueness alone? |
|---|---|
Upsert a record by Virtuous ID (UPDATE WHERE id = ? or INSERT ... ON CONFLICT DO UPDATE) | Yes — multiple writes produce the same final state. |
| Insert a record with auto-generated ID | No — each delivery creates a new row. |
Increment a counter (UPDATE ... SET count = count + 1) | No — each delivery increments. |
| Send an email | No — each delivery sends another email. |
| Charge a credit card | No — each delivery is a separate charge. |
| Call a downstream API that creates a record | Usually no — depends on the API. |
| Append an event to an audit log | No — each delivery appends. |
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
| State | Meaning |
|---|---|
| Unclaimed | Event has not been seen yet. The first delivery transitions to claimed. |
| Claimed | Event is currently being processed. Subsequent deliveries should wait or skip. |
| Completed | Event was fully processed including side effects. Subsequent deliveries are no-ops. |
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 withSET ... NX:
JavaScript
INSERT ... ON CONFLICT DO NOTHING:
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
JavaScript
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
${eventId}-email for the email, ${eventId}-stripe-charge for the charge. The downstream service handles dedup.
JavaScript
Handling out-of-order deliveries
Webhooks can arrive out of chronological order — particularly after a retry sequence. Acontact.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
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.
Cross-source duplicates
A specifically partner-relevant duplicate scenario: your integration both submits Transactions and subscribes to webhooks. When you submit a Gift Transaction viaPOST /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
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
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.