← Back to Blog
Guide webhooks idempotency reliability architecture backend

Idempotent Webhook Handling: A Practical Guide

Your webhook handler will receive the same event more than once. Network hiccups, retry logic, and race conditions make duplicates inevitable. Building an idempotent handler is how you stay sane.

JW

Jason Warner

March 25, 2026

Idempotent Webhook Handling: A Practical Guide

An idempotent operation is one where performing it multiple times has the same effect as performing it once. For webhook handlers, this means: processing the same event three times should produce exactly the same result as processing it once.

This is not optional. It is a requirement for any webhook handler in production.

Why Duplicate Events Are Inevitable

Source retries. Stripe retries for 72 hours. GitHub retries 3 times. Shopify retries 19 times. If your endpoint returns a 5xx or times out, the source retries — even if you already processed it before the timeout.

Network duplicates. Rare but possible — TCP-level retransmission can cause a request to arrive twice.

Infrastructure retries. A webhook relay retrying a failed delivery from its side.

Your own replay. When you replay events to recover from an outage, you're intentionally reprocessing.

Design for duplicates from day one.

The Idempotency Key Pattern

Every webhook event has a unique identifier. Use it. Before processing, check whether you've seen that ID before.

async function handleWebhook(event) {
  const eventId = event.id;

  const seen = await db.query(
    'SELECT 1 FROM processed_webhooks WHERE event_id = $1', [eventId]
  );
  if (seen.rows.length > 0) return { skipped: true };

  await processEvent(event);

  await db.query(
    'INSERT INTO processed_webhooks (event_id, processed_at) VALUES ($1, NOW())',
    [eventId]
  );
  return { processed: true };
}

Where to Find the Idempotency Key

Service Key
Stripe event.id (e.g., evt_1234)
GitHub X-GitHub-Delivery header (UUID)
Shopify X-Shopify-Webhook-Id header
Twilio MessageSid in payload

Handling the Race Condition

The pattern above has a race condition: two instances can both pass the "already seen" check simultaneously.

Use a unique constraint on event_id:

try {
  await db.query(
    'INSERT INTO processed_webhooks (event_id) VALUES ($1)', [eventId]
  );
} catch (err) {
  if (err.code === '23505') return { skipped: true }; // unique violation
  throw err;
}

// Safe to process — we hold the unique row
await processEvent(event);

Idempotent Business Logic

Beyond deduplication, design your business logic to be safe to run multiple times.

Use upsert instead of insert:

// Fragile
await db.users.create({ stripeId: event.data.customerId });

// Idempotent
await db.users.upsert(
  { stripeId: event.data.customerId },
  { status: 'active', updatedAt: new Date() }
);

Use absolute state, not increments:

// Fragile — double-processing charges twice
await db.accounts.increment('balance', event.data.amount);

// Idempotent — sets to the correct value
await db.accounts.update(
  { id: event.data.accountId },
  { balance: event.data.newBalance, lastEventId: event.id }
);

Check current state before acting:

const sub = await db.subscriptions.findOne({ userId });
if (sub.status === 'active') return; // already done
await activateSubscription(userId);

Retention Policy

Keep processed event IDs long enough to cover the retry window of any source. Stripe retries for 72 hours, so keep IDs for at least 7 days. Add a cleanup job:

DELETE FROM processed_webhooks
WHERE processed_at < NOW() - INTERVAL '7 days';

Testing Idempotency

test('processing the same event twice is idempotent', async () => {
  const event = buildTestEvent({ type: 'payment_intent.succeeded', id: 'evt_test' });

  await handleWebhook(event);
  await handleWebhook(event); // second call — should be a no-op

  const activations = await db.subscriptions.count({
    where: { stripeEventId: 'evt_test' }
  });
  expect(activations).toBe(1);
});

Bluejay Relay logs every event with its unique ID, making duplicate delivery auditable. On the paid plans, replay a specific event ID as many times as needed for testing. Start free.

#webhooks #idempotency #reliability #architecture #backend

Build more reliable webhook workflows.

Capture, transform, and retry webhooks with full observability. Free to start, no credit card.