Skip to content
OTFotf
All posts

Stripe sent the same purchase webhook 4 times. Idempotency saved us 4 emails.

D
DaveAuthor
Stripe sent the same purchase webhook 4 times. Idempotency saved us 4 emails.

Stripe webhook delivery is at-least-once, not exactly-once. Here is the atomic dedupe pattern our SaaS kit ships with by default.

Understanding Stripe Webhook Delivery

Stripe guarantees at-least-once delivery for webhooks, meaning your endpoint may receive the same event multiple times. This design ensures reliability but requires your system to handle duplicate events gracefully. Simply acknowledging a webhook does not prevent duplicates, so your backend must be prepared to process repeated notifications without causing unintended side effects.

The Problem with Duplicate Webhooks

Imagine receiving the same purchase event four times in quick succession. Without safeguards, your system might send four confirmation emails, create multiple invoices, or double-charge customers. These unintended consequences can frustrate users and complicate reconciliation. Handling duplicates is critical to maintaining data integrity and a smooth user experience.

Atomic Deduplication Pattern

Our SaaS kit ships with an atomic deduplication pattern that ensures each webhook event is processed exactly once. The core idea is to use the event's unique id as a key in your database. When a webhook arrives, your system attempts to insert this ID into a dedupe table or mark it as processed in a transaction. If the ID already exists, the event is ignored, preventing duplicate processing. This approach leverages database atomicity to guarantee idempotency.

Implementing Idempotency in Your SaaS

Follow these steps to implement atomic deduplication for Stripe webhooks in your SaaS application:

1

Extract the event ID

From the incoming webhook payload, extract the unique `id` field that Stripe assigns to each event.

ts
const eventId = req.body.id;
2

Attempt to insert the event ID atomically

Use a database transaction or an atomic insert to record the event ID. If the insert fails because the ID already exists, skip further processing.

ts
await db.transaction(async (tx) => {
  const inserted = await tx.insert('processed_events', { id: eventId });
  if (!inserted) throw new Error('Duplicate event');
  // continue processing
});
3

Process the event payload

Only after successfully recording the event ID, proceed with your business logic, like sending emails or updating records.

ts
// Your event handling logic here
await sendConfirmationEmail(userEmail);
4

Respond to Stripe

Return a 2xx HTTP status to acknowledge successful processing, so Stripe stops retrying.

ts
res.status(200).send('OK');

Best Practices and Tips

Use a dedicated deduplication store

Keep a separate table or collection to track processed event IDs for clarity and performance.

Set a retention policy

Periodically clean up old event IDs to prevent unbounded growth of your dedupe store.

Test with duplicate events

Simulate repeated webhook deliveries during development to verify your idempotency logic.

Log duplicate attempts

Logging when duplicates are detected can help diagnose issues and monitor webhook behavior.

backendkitsarchitecture