DEV Community

Cover image for Handling Duplicate Shopify Webhook Events (And Why You Must)
Muhammad Masad Ashraf
Muhammad Masad Ashraf

Posted on

Handling Duplicate Shopify Webhook Events (And Why You Must)

You built a Shopify integration. It works great in dev. Then in production,
a customer gets charged twice. Or an order ships twice. Or your inventory
goes negative overnight.

The culprit almost always? Duplicate webhook events.

Shopify guarantees at-least-once delivery, not exactly-once. Your endpoint
will receive the same event more than once. Here is how to handle it properly.


Why Duplicates Happen

Shopify retries a webhook if your server does not respond with 2xx within
5 seconds. It retries up to 19 times over 48 hours.

Duplicates hit you when:

  • Your server is slow to respond
  • A network timeout occurs mid-request
  • Your server restarts while processing
  • A queue consumer crashes and re-pulls the job

Step 1: Respond Immediately, Process Later

Never do heavy work inside the webhook handler. Respond fast, queue the work.

app.post('/webhooks/orders-paid', async (req, res) => {
  res.status(200).send('OK'); // Shopify gets its response immediately
  await queue.push({ topic: 'orders/paid', payload: req.body });
});
Enter fullscreen mode Exit fullscreen mode

Step 2: Build a Dedup Key

Do NOT use X-Shopify-Webhook-Id as your dedup key. That header changes
on every retry attempt. Use the resource ID from the payload instead.

const dedupKey = `orders/paid:${payload.id}`;
Enter fullscreen mode Exit fullscreen mode

This stays the same across all retries for the same event.


Step 3: Check Redis Before Processing

const alreadySeen = await redis.get(dedupKey);

if (alreadySeen) {
  console.log('Duplicate detected, skipping:', dedupKey);
  return;
}

await redis.setex(dedupKey, 86400, '1'); // TTL: 24 hours
Enter fullscreen mode Exit fullscreen mode

Step 4: Add a Database Safety Net

Redis can go down. Your database should be the last line of defense.

CREATE TABLE processed_webhook_events (
  dedup_key VARCHAR(255) UNIQUE NOT NULL,
  processed_at TIMESTAMP DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode
const result = await db.raw(`
  INSERT INTO processed_webhook_events (dedup_key)
  VALUES (?)
  ON CONFLICT (dedup_key) DO NOTHING
  RETURNING id
`, [dedupKey]);

if (result.rows.length === 0) return; // Already processed
Enter fullscreen mode Exit fullscreen mode

The ON CONFLICT DO NOTHING is atomic. Even 10 concurrent requests for
the same event will only insert once.


Step 5: Make Your Handler Idempotent

Dedup catches most duplicates. Idempotent logic catches the rest.

For inventory, always set absolute values, never increment or decrement:

// BAD - breaks on duplicate
await db.inventory.decrement({ quantity: 5 });

// GOOD - safe to run multiple times
await db.inventory.update({ quantity: newAbsoluteValue });
Enter fullscreen mode Exit fullscreen mode

High-Risk Events to Watch

Event Risk Fix
orders/paid Double fulfillment DB unique constraint
inventory_levels/update Wrong stock count Use absolute values
refunds/create Double refund Check refund ID first
customers/create Duplicate accounts Check email uniqueness

Quick Checklist Before You Ship

  • [ ] Webhook responds in under 5 seconds
  • [ ] Processing is async
  • [ ] Dedup key = topic + resource ID
  • [ ] Redis check at entry point
  • [ ] DB unique constraint as fallback
  • [ ] Inventory uses absolute values
  • [ ] Load tested with concurrent duplicate requests

That's the full pattern. Two layers of protection: Redis for speed,
database for correctness. Your handlers stay idempotent as a safety net.

Full guide with queue-level dedup (SQS + BullMQ) and monitoring setup
on our blog: kolachitech.com

Top comments (0)