Blog / Webhooks

Webhook idempotency means your handler processes each event only once — even if the same event is delivered multiple times. Stripe and Shopify may redeliver a webhook if your endpoint was slow or returned a non-2xx, so a handler that isn't idempotent risks charging customers twice, fulfilling orders twice, or creating duplicate records.

Webhook Idempotency: Why You Must Handle Duplicate Events

· 7 min read · Webhook best practices

Your Stripe checkout.session.completed handler fires. It provisions the customer's account, sends a welcome email, and creates a subscription record in your database. Everything works. Then, 30 seconds later, the same event arrives again — because your endpoint was slow to respond and Stripe's first delivery timed out. Your handler fires a second time. The customer gets two welcome emails. Your database has a duplicate subscription row. If you're incrementing usage counters or charging a card, it happens twice.

This is the problem idempotency solves. It's not theoretical — any webhook platform that retries on failure can and will redeliver the same event. Here's how to implement idempotency correctly.

Why do platforms redeliver webhook events?

Webhook platforms retry delivery when they don't receive a clean success signal from your endpoint. Specifically:

Stripe retries on an exponential backoff schedule for up to 72 hours — up to 11 attempts per event. Shopify retries 19 times over 48 hours. Every retry is a potential duplicate.

What is an idempotency key and where does each platform put it?

An idempotency key is a unique, stable identifier for a specific event. The same event delivered multiple times always carries the same key. You use it to check whether you've already processed that event before doing any work.

How do you implement idempotency in a webhook handler?

The pattern is straightforward: before doing any work, record the event ID in a database table. If the record already exists, skip processing and return 200. Here's the implementation in Node.js:

-- SQL: create the idempotency table
CREATE TABLE processed_events (
  event_id   TEXT PRIMARY KEY,
  platform   TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
// Node.js: idempotent Stripe webhook handler (Express)
import Stripe from 'stripe';
import { db } from './db';

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook signature error: {err.message}`);
  }

  // Idempotency check — respond 200 immediately if already processed
  const existing = await db.query(
    'SELECT 1 FROM processed_events WHERE event_id = $1',
    [event.id]
  );

  if (existing.rows.length > 0) {
    return res.status(200).json({ status: 'already_processed' });
  }

  // Mark as processed before doing any work
  await db.query(
    'INSERT INTO processed_events (event_id, platform) VALUES ($1, $2)',
    [event.id, 'stripe']
  );

  // Respond 200 immediately, process asynchronously
  res.status(200).json({ received: true });

  // Queue the real work — do not await this before responding
  processStripeEvent(event).catch(console.error);
});

The PRIMARY KEY constraint on event_id means that even under concurrent load — two simultaneous deliveries of the same event — only one INSERT will succeed. The other will throw a unique constraint violation, which you should catch and treat as "already processed."

Why should you respond 200 immediately and process asynchronously?

Stripe and Shopify have short response timeouts. If your handler does everything synchronously — verifies the signature, inserts the idempotency record, runs business logic, calls downstream APIs, sends emails — it's very easy to exceed the timeout. When you do, the platform marks the delivery as failed and retries, creating the exact duplicate problem you're trying to prevent.

The correct pattern is:

  1. Verify the signature (fast — in-memory HMAC check)
  2. Check and insert idempotency key (fast — single indexed DB query)
  3. Respond 200 OK
  4. Queue the actual work (pg-boss, BullMQ, SQS, or even setImmediate for simple cases)

The idempotency check must happen synchronously before the 200 response, not inside the async job. Otherwise two concurrent deliveries can both receive 200 and both enqueue the job.

What operations are unsafe without idempotency?

Not all operations are equally dangerous to duplicate. Understanding the difference helps you prioritize:

Even if your core business logic is idempotent, side effects like emails and Slack notifications almost never are. The idempotency table protects all of them.

What happens if you don't implement idempotency?

The failure modes depend on what your handler does:

FAQ: Webhook idempotency

What is an idempotency key in webhooks?
An idempotency key is a unique identifier for a webhook event that your handler uses to determine whether it has already processed that event. For Stripe, this is the event's id field (e.g. evt_1abc...). For Shopify, it's the X-Shopify-Webhook-Id request header. You store this key in a database table and check it before processing — if the key already exists, skip processing and return 200 immediately.
How does Stripe handle duplicate webhook deliveries?
Stripe retries a webhook delivery if your endpoint returns a non-2xx HTTP response or times out after 30 seconds. It may also redeliver an event if you click Resend in the dashboard. In both cases, the redelivered event carries the same event id — so checking for that ID in a processed_events table is sufficient to deduplicate all retry-driven duplicates.
How do I make my webhook handler idempotent?
Store the event ID in a database table with a unique constraint before processing. On every incoming event: check if the ID exists — if yes, return 200 immediately without processing; if no, insert the ID then queue the real work. Respond 200 before the async processing so you don't risk timing out and triggering another retry.

Know when every webhook fails — before you accidentally process a duplicate. Connect Stripe to Webhook Guardian and get alerted within 5 minutes of any failed delivery, with event payload and one-click replay. Start free for 14 days →

Also read: Webhook reliability best practices · How to know when a Stripe webhook fails · Stripe webhook monitoring