Idempotent Webhook Handlers: Why and How

What Is Idempotency?

An operation is idempotent if performing it multiple times produces the same result as performing it once. In the context of webhooks, an idempotent handler produces the same outcome whether a particular event is delivered once, twice, or ten times.

This matters because webhook providers deliver events at least once, not exactly once. Network issues, timeouts, retries, and provider bugs can all cause the same event to be delivered multiple times. If your handler is not idempotent, duplicate deliveries can cause duplicate orders, double charges, incorrect counts, or inconsistent application state.

Consider a payment webhook handler that credits a user's account balance. If the handler is not idempotent and the webhook is delivered twice, the user gets credited twice — once legitimately and once erroneously. Idempotent handlers detect the duplicate delivery and skip the second processing.

Event ID Deduplication

The simplest idempotency strategy is deduplication using the event's unique ID. Every major webhook provider includes a unique identifier for each event — Stripe has id (like evt_1234), GitHub has X-GitHub-Delivery header, Shopify has X-Shopify-Webhook-Id.

Store processed event IDs in a database table and check for existence before processing:

javascript
async function handleWebhook(event) {
  const { id: eventId, type, data } = event;

// Atomic check-and-insert const [record, created] = await ProcessedEvent.findOrCreate({ where: { eventId }, defaults: { eventId, type, processedAt: new Date() } });

if (!created) { // Event already processed — skip console.log(Duplicate event ${eventId}, skipping); return; }

// First time seeing this event — process it await processEventData(type, data); } ```

The findOrCreate pattern is important: it atomically checks and inserts in a single database operation. This prevents a race condition where two concurrent deliveries of the same event both pass the existence check and both process the event.

Natural Idempotency Keys

Sometimes you can make operations naturally idempotent by using the event data itself as constraints. Instead of tracking event IDs, design your database operations to be inherently safe for duplicates.

For example, instead of incrementing a counter ("add 1 to the order count"), set an absolute value ("set order status to 'paid'"). Setting a value is naturally idempotent — setting it twice has the same effect as setting it once. Incrementing is not idempotent — incrementing twice doubles the effect.

Use database upserts (INSERT ON CONFLICT UPDATE) to make create-or-update operations idempotent:

javascript
// Idempotent: uses upsert with external ID
await db.subscriptions.upsert({
  where: { stripeSubscriptionId: event.data.id },
  create: {
    stripeSubscriptionId: event.data.id,
    status: event.data.status,
    userId: event.data.metadata.userId,
  },
  update: {
    status: event.data.status,
  },
});

This approach works well for events that create or update resources. The external ID (like stripeSubscriptionId) acts as a natural deduplication key.

Handling Event Ordering

Webhook events may arrive out of order. An "updated" event might arrive before a "created" event, or two rapid updates might arrive in reverse order. Your handler should handle these cases gracefully.

One approach is to use timestamps for ordering. Include the event timestamp in your processing logic and skip events that are older than the current state:

javascript
async function handleSubscriptionUpdate(event) {
  const sub = await db.subscriptions.findUnique({
    where: { externalId: event.data.id }
  });

if (sub && sub.lastUpdated >= event.created) { // We already have a more recent update — skip return; }

await db.subscriptions.upsert({ where: { externalId: event.data.id }, create: { /* ... */ lastUpdated: event.created }, update: { /* ... */ lastUpdated: event.created }, }); } ```

For events that depend on a specific order (like a state machine: created -> active -> cancelled), validate the state transition before applying it. If the current state is "cancelled" and you receive a "created" event (likely out of order), you may want to skip it or log a warning.

Testing Idempotency with ReqPour

ReqPour's replay feature is built for testing idempotency. The workflow is straightforward: trigger a real webhook event, verify it processes correctly, then replay the same event from the ReqPour dashboard. Your handler should detect the duplicate and skip processing.

Test these scenarios: replay immediately after first processing (tests basic deduplication), replay after a delay (tests that your deduplication store persists), and replay multiple times rapidly (tests concurrent duplicate handling).

Also test ordering issues by capturing multiple related events in ReqPour, then replaying them in reverse order. Verify that your handler produces the correct final state regardless of the order events arrive in.

Check your database after replay to verify no duplicate records were created, no counters were double-incremented, and no side effects (like sending emails or making API calls) were triggered twice. The ReqPour dashboard shows your handler's response for each replayed request, confirming it returned 200 (acknowledged the duplicate) rather than processing it again.

Get started with ReqPour

Catch, inspect, and relay webhooks to localhost. Free to start, $3/mo for Pro.