Handling Duplicate Shopify Webhook Events (And Why You Must)

Published: (May 25, 2026 at 02:23 PM EDT)
4 min read
Source: Dev.to

Source: Dev.to

The Problem: Duplicate Shopify Webhook Events

Shopify guarantees at‑least‑once delivery for webhooks, not exactly‑once.
If your endpoint does not respond with a 2xx status within 5 seconds, Shopify retries the webhook up to 19 times over 48 hours. This can result in the same event being delivered multiple times.

Common Causes of Duplicates

CauseExplanation
Slow server responseShopify times out and retries.
Network timeout mid‑requestThe request never reaches your handler fully.
Server restart while processingIn‑flight request is lost, triggering a retry.
Queue consumer crashThe job is re‑queued and processed again.

Immediate Mitigation: Fast Acknowledgement & Async Processing

Never perform heavy work inside the webhook handler. Respond quickly and hand the payload off to a background queue.

// Express.js example
app.post('/webhooks/orders-paid', async (req, res) => {
  // Immediate 2xx response for Shopify
  res.status(200).send('OK');

  // Enqueue the work for asynchronous processing
  await queue.push({ topic: 'orders/paid', payload: req.body });
});

Deduplication Strategy

1. Use a Stable Deduplication Key

Do not use X-Shopify-Webhook-Id (it changes on retries).
Instead, build a key from the webhook topic and the resource ID, which stays constant across retries.

const dedupKey = `orders/paid:${payload.id}`; // stable across retries

2. Fast Check with Redis

// Check if the event was already seen
const alreadySeen = await redis.get(dedupKey);
if (alreadySeen) {
  console.log('Duplicate detected, skipping:', dedupKey);
  return;
}

// Mark as seen for the next 24 hours
await redis.setex(dedupKey, 86400, '1'); // TTL = 24 h

3. Persistent Fallback in the Database

Redis may go down; a unique constraint in your primary datastore provides a reliable last line of defense.

-- PostgreSQL table for processed webhook events
CREATE TABLE processed_webhook_events (
  dedup_key VARCHAR(255) UNIQUE NOT NULL,
  processed_at TIMESTAMP DEFAULT NOW()
);
// Attempt to insert the dedup key atomically
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) {
  // Row already existed → duplicate
  return;
}

The ON CONFLICT DO NOTHING clause ensures that even with many concurrent requests, only the first succeeds.

Idempotent Business Logic

Inventory Updates

Never rely on increment/decrement operations; they break when a webhook is processed twice.

// ❌ Bad – not idempotent
await db.inventory.decrement({ quantity: 5 });
// ✅ Good – idempotent (set absolute quantity)
await db.inventory.update({ quantity: newAbsoluteValue });

Example Event‑Specific Risks & Fixes

EventRiskFix
orders/paidDouble fulfillmentDB unique constraint on dedup key
inventory_levels/updateWrong stock countUse absolute values instead of deltas
refunds/createDouble refundCheck refund ID before issuing
customers/createDuplicate accountsEnforce email uniqueness

Checklist for a Robust Webhook Handler

  • Respond to Shopify within 5 seconds (2xx status)
  • Offload processing to an asynchronous queue
  • Deduplication key = topic + resource ID
  • Perform Redis lookup at the entry point
  • Have a DB unique constraint as a fallback
  • Use absolute values for inventory updates
  • Load‑test with concurrent duplicate requests

Full Pattern Overview

  1. Fast ACK → 2xx response to Shopify.
  2. Queue → Background job handles business logic.
  3. Redis dedup → Quick detection of repeats.
  4. DB unique constraint → Guarantees eventual consistency.
  5. Idempotent operations → Safe to run multiple times.

Implementing these two layers of protection (in‑memory cache + persistent uniqueness) together with idempotent business logic eliminates the majority of duplicate‑processing issues in production Shopify integrations.

0 views
Back to Blog

Related posts

Read more »