Webhook Idempotency

How to handle duplicate webhook events — three proven strategies with code examples.

Last updated March 2026. Code examples in Node.js, applicable to any language.

Why Duplicates Happen

If you process webhooks long enough, you will receive duplicates. It's not a matter of if, but when. Here's why:

Real-world impact: A duplicate payment_intent.succeeded event processed twice means a customer gets charged twice, or receives two confirmation emails. A duplicate customer.subscription.deleted processed twice could trigger two cancellation workflows. At 10,000 events/day with a 0.1% duplicate rate, that's 10 duplicate events per day that could cause real damage.
Strategy 1

Idempotency Keys

The simplest and most common approach: store the event ID before processing, and check for it on every delivery. If the ID already exists, skip processing and return 200.

How it works:

  1. Extract the event ID from the webhook payload (e.g., Stripe's id field, GitHub's X-GitHub-Delivery header).
  2. Check if this ID exists in your deduplication store (Redis, database, etc.).
  3. If it exists, return 200 immediately — the event was already processed.
  4. If it doesn't exist, store the ID with a TTL and process the event.

Node.js + Redis Example

const Redis = require('ioredis');
const redis = new Redis();

async function handleWebhook(req, res) {
  const event = JSON.parse(req.body);
  const eventId = event.id; // e.g., "evt_1Nq..."

  // Try to set the key — NX means "only if it doesn't exist"
  // EX 259200 = 72 hour TTL (covers Stripe's 3-day retry window)
  const isNew = await redis.set(
    `webhook:${eventId}`,
    'processing',
    'EX', 259200,
    'NX'
  );

  if (!isNew) {
    // Already processed — skip, but return 200 so provider stops retrying
    console.log(`Duplicate event ${eventId}, skipping`);
    return res.status(200).json({ received: true, duplicate: true });
  }

  try {
    // Process the event (charge customer, send email, etc.)
    await processEvent(event);

    // Mark as completed
    await redis.set(`webhook:${eventId}`, 'completed', 'EX', 259200);
  } catch (err) {
    // Processing failed — delete the key so retries can try again
    await redis.del(`webhook:${eventId}`);
    return res.status(500).json({ error: 'Processing failed' });
  }

  res.status(200).json({ received: true });
}

Key detail: Use Redis SET ... NX (set-if-not-exists) for atomic check-and-set. This prevents a race condition where two duplicate deliveries arrive at the same millisecond and both pass the "does it exist?" check.

Strategy 2

Database Unique Constraints

If you're already writing webhook data to a database, let the database enforce uniqueness. Use INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) or INSERT IGNORE (MySQL) to silently skip duplicates.

PostgreSQL INSERT ON CONFLICT

-- Create the table with a unique constraint on event_id
CREATE TABLE webhook_events (
  id           SERIAL PRIMARY KEY,
  event_id     TEXT NOT NULL UNIQUE,
  event_type   TEXT NOT NULL,
  payload      JSONB NOT NULL,
  processed_at TIMESTAMPTZ DEFAULT NOW()
);

-- Insert — silently does nothing if event_id already exists
INSERT INTO webhook_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING
RETURNING id;

Node.js Usage

async function handleWebhook(req, res) {
  const event = JSON.parse(req.body);

  const result = await db.query(
    `INSERT INTO webhook_events (event_id, event_type, payload)
     VALUES ($1, $2, $3)
     ON CONFLICT (event_id) DO NOTHING
     RETURNING id`,
    [event.id, event.type, event]
  );

  if (result.rows.length === 0) {
    // Conflict — event already processed
    return res.status(200).json({ received: true, duplicate: true });
  }

  // New event — process it
  await processEvent(event);
  res.status(200).json({ received: true });
}

Advantage: No separate deduplication store needed — your database does double duty. The unique constraint is enforced at the database level, so it's safe even with concurrent requests across multiple application instances.

Strategy 3

State Machine

For complex workflows where the same entity goes through multiple states (e.g., order: pending → paid → shipped → delivered), use a state machine pattern. Only process a webhook event if the current state allows the transition.

const VALID_TRANSITIONS = {
  'pending':   ['paid', 'cancelled'],
  'paid':      ['shipped', 'refunded'],
  'shipped':   ['delivered', 'returned'],
  'delivered': ['returned'],
  'cancelled': [],
  'refunded':  [],
  'returned':  [],
};

async function handleOrderWebhook(event) {
  const orderId = event.data.object.metadata.order_id;
  const newState = mapEventToState(event.type);

  // Atomic update — only transitions if current state allows it
  const result = await db.query(
    `UPDATE orders
     SET status = $1, updated_at = NOW()
     WHERE id = $2
       AND status = ANY($3)
     RETURNING id, status`,
    [newState, orderId, VALID_TRANSITIONS_TO[newState]]
  );

  if (result.rows.length === 0) {
    // State doesn't allow this transition — likely a duplicate or out-of-order event
    console.log(`Order ${orderId}: transition to ${newState} not allowed from current state`);
    return; // Still return 200 to the webhook provider
  }

  console.log(`Order ${orderId}: transitioned to ${newState}`);
}

When to use: State machines are ideal when events represent state transitions and the order matters. A duplicate payment_intent.succeeded event won't re-process because the order is already in paid state, and paid → paid is not a valid transition.

Combining Strategies

In production, most teams combine two or more strategies for defense in depth:

How SDNBros Helps

1

Replay Safety Verdicts

When you replay a failed webhook event in SDNBros, it tells you whether the replay is safe — whether the payload and idempotency key match the original delivery. This prevents accidental re-processing when investigating production issues.

2

Deduplication Fingerprints

SDNBros generates a normalized fingerprint for each webhook event — a hash of the event type, key fields, and payload structure. You can use this fingerprint in your own deduplication logic, especially for providers that don't include a stable event ID.

3

Duplicate Delivery Testing

Use the SDNBros Sandbox to test your idempotency implementation. Replay the same event multiple times against your endpoint and verify that side effects only happen once. The sandbox shows exactly what your endpoint returned for each delivery.

See replay safety in action

Test your webhook handler with duplicate events in the SDNBros Sandbox. Replay events, inspect responses, and verify your idempotency logic works.

Open Sandbox Testing Checklist

Related guides: What is a Webhook? · Webhook vs API · Webhook Security · Retry Strategies · Testing Checklist

More: What is a Webhook? | Webhook Security Guide | Testing Checklist | Retry Strategies | Compare Tools