How to handle duplicate webhook deliveries in Raise — the keying strategies, deduplication storage patterns, and the operational practices that make at-least-once delivery safe.
Raise webhook delivery is at-least-once: the same event can arrive at the partner endpoint more than once. Retries on transient failures, timing edge cases where the partner endpoint processes but acknowledges slowly, and (rarely) platform-level redeliveries all produce duplicates that the partner side must handle.Without explicit idempotency handling, duplicate deliveries produce duplicate side effects — duplicate emails to donors, duplicate gift records in downstream systems, duplicate Slack notifications. This page covers the keying strategies, the deduplication storage patterns, and the operational practices that make at-least-once delivery safe.
Three scenarios produce duplicate webhook deliveries:
Scenario
Description
Retry after timeout
Partner endpoint processes successfully but is slow to respond. Raise times out, retries. Partner now receives the same event twice.
Retry after partial failure
Partner endpoint accepts the event but a downstream system fails. Partner returns 5xx. Raise retries. Partner has to ensure the second attempt doesn’t double-process.
Platform-level redelivery
Rare, but possible during platform maintenance or recovery scenarios. The platform may re-fire events that were marked as delivered.
The first two are common. The third is rare but worth handling defensively.The partner integration’s job: detect “I’ve already processed this event” before producing any side effects. The simplest reliable mechanism is a deduplication store.
A dedup store records the unique identifier of every event the integration has processed. Before processing a new event, check the store. If the event is already there, skip processing. If not, add it and proceed.
JavaScript
class DeduplicationStore { async hasProcessed(eventKey) { return Boolean(await this.db.findByKey(eventKey)); } async recordProcessed(eventKey, metadata = {}) { await this.db.insert({ key: eventKey, processedAt: new Date(), ...metadata, }); }}// Usage in the event processorasync function processEvent(event) { const key = buildEventKey(event); if (await dedupStore.hasProcessed(key)) { console.log(`Already processed ${key} — skipping`); return; } await performSideEffects(event); await dedupStore.recordProcessed(key, { eventType: event.eventType });}
The store’s choice of database is flexible — Postgres, DynamoDB, Redis with persistence, anything that supports atomic key lookups with reasonable latency. The key requirement: the store must persist across the integration’s restarts (in-memory caches don’t count) and provide consistent reads within the deduplication window.
The simplest reliable key. The contextId from the webhook log is the ID of the entity that triggered the event (the Gift ID, Donor ID, etc.); combined with the event type integer, it uniquely identifies the event:
JavaScript
function buildEventKey(event) { return `${event.eventType}:${event.contextId}`;}
This treats a single gift created event (eventType 10, contextId 9876) as the same event regardless of how many times it’s delivered.Strengths: simple, durable, works without depending on undocumented event payload structure.Weakness: doesn’t distinguish multiple legitimate updates to the same resource. A gift updated event for gift 9876 today is the same key as a gift updated event for gift 9876 tomorrow — they’ll be treated as duplicates.To handle multiple legitimate updates, see Option 3 below.
Strengths: distinguishes events by their actual content, so multiple legitimate updates produce distinct keys.Weakness: depends on field names in the payload (id, modifiedDate) being stable. If Raise changes the payload shape, the key changes. Use a small, stable subset of fields rather than hashing the whole payload.
This treats each update of a resource as a distinct event (different modifiedDate) but treats duplicate deliveries of the same update as the same event.Recommended for most integrations. Distinguishes legitimate updates while still deduplicating delivery retries.
If Raise’s webhook payload or headers include a delivery ID (a unique identifier per delivery attempt), use that. This is the cleanest key — each delivery is uniquely identified.
The Raise OpenAPI spec doesn’t document a per-delivery identifier in the webhook payload or headers. Inspect real webhook deliveries (via the log endpoints or your endpoint’s request logs) to check whether one is included. If so, use it as the dedup key.
Keep dedup records for a bounded window (e.g., 7 days) and discard older entries. Acceptable when:
Retry attempts complete within the window (typically minutes to hours, well under 7 days).
Storage cost matters and the integration processes high event volume.
JavaScript
async function recordProcessed(key) { await db.insert({ key, processedAt: new Date(), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), });}// Periodic cleanupasync function cleanupExpiredDedupEntries() { await db.deleteWhere({ expiresAt: { lt: new Date() } });}
Set the retention window longer than Raise’s maximum retry duration. If retries can continue for up to 24 hours, keep dedup records for at least 48 hours.
Re-processing risk extends beyond the retry window (e.g., manual replays during incident recovery).
The integration’s audit requirements include “we processed event X at time T.”
For most partner integrations, permanent retention is simpler and avoids edge cases. A dedup table that grows by ~1 row per gift is manageable — even a million gifts a year is a small table.
A subtle race condition: two duplicate deliveries arrive simultaneously, both check the dedup store (neither finds the key), and both proceed to process. To prevent this, the check-and-record needs to be atomic.
Insert the dedup record first, with a unique constraint on the key. If the insert succeeds, you “won” the race — proceed to process. If the insert fails (unique constraint violation), another worker is already processing — skip.
JavaScript
async function processEvent(event) { const key = buildEventKey(event); try { await db.insert('dedup', { key, processedAt: new Date() }); } catch (err) { if (isUniqueConstraintError(err)) { console.log(`Event ${key} already being processed — skipping`); return; } throw err; } // We won the insert — safe to process await performSideEffects(event);}
Strengths: race-condition-safe with no explicit locks.Weakness: if processing fails after the insert, the dedup record stays and prevents legitimate retries. Handle by adding a status field and clearing on failure:
JavaScript
async function processEvent(event) { const key = buildEventKey(event); try { await db.insert('dedup', { key, status: 'processing', startedAt: new Date() }); } catch (err) { if (isUniqueConstraintError(err)) return; throw err; } try { await performSideEffects(event); await db.update('dedup', { key }, { status: 'completed', completedAt: new Date() }); } catch (err) { // Processing failed — remove the dedup record so retries can succeed await db.delete('dedup', { key }); throw err; }}
Strengths: cleanest semantics — either everything happens (dedup record + side effects) or nothing.Weakness: requires side effects that work within the transaction. External calls (email sends, third-party API calls) typically don’t.
The dedup store handles the “I’ve seen this before” check. But for side effects that touch external systems, idempotency at the side-effect layer adds robustness.
If your integration writes to a downstream system that supports idempotency keys (e.g., Stripe, many modern APIs), pass the event key as the idempotency key:
If your integration writes records to a database, use upserts keyed by a stable identifier (the Raise resource ID, typically):
JavaScript
async function syncGiftToDownstream(gift) { // Upsert keyed by Raise gift ID — duplicate calls produce a single row await db.upsert('gifts', { raiseGiftId: gift.id, amount: gift.amount, date: gift.date, donorEmail: gift.donor?.email, lastSyncedAt: new Date(), });}
This way, even if the same event is processed twice, the database ends up with one record per gift rather than two.
Email and Slack notifications are typically not idempotent at the platform level — sending the same email twice produces two emails in the donor’s inbox. For these side effects, the dedup store check is the only defense. Make sure it runs before the notification is sent.
JavaScript
async function sendThankYouEmail(event) { const key = buildEventKey(event); if (await emailDedup.hasSent(key)) return; await emailService.send({ to: event.payload.donor.email, ... }); await emailDedup.recordSent(key);}
A separate emailDedup store (distinct from the main event dedup store) lets you track “we already sent this specific email” independent of the broader event processing.
Sometimes you want to reprocess an event — for example, recovering from a bug in the original processing logic, or replaying events after a downstream system restoration.
For wider recoveries, query the webhook logs across a time window and reprocess each:
JavaScript
async function reprocessWindow(webhookId, startDate, endDate) { // Paginate through logs for the time window const logs = await streamLogsForWindow(webhookId, startDate, endDate); for (const log of logs) { if (log.success !== 'Yes' && log.success !== 'True') continue; try { await reprocessEvent(webhookId, log.id); } catch (err) { console.error(`Reprocess failed for log ${log.id}:`, err); } }}
For very large windows, throttle the reprocessing — bulk reprocessing of thousands of events can produce a thundering-herd effect on downstream systems. Process at a controlled pace.
Include tests that explicitly fire the same event twice and verify the second processing is skipped. This is easy to forget — the happy path of single-delivery events doesn’t exercise the dedup logic — but it’s the most important test for production reliability.
JavaScript
test('processing the same event twice produces no duplicate side effects', async () => { const event = buildTestEvent({ giftId: 9876 }); await processEvent(event); await processEvent(event); // Same event again expect(downstream.giftRecords).toHaveLength(1); expect(emailService.sentEmails).toHaveLength(1);});
A test of this shape, run in CI, catches dedup regressions before they reach production.