Handling Duplicate Shopify Webhook Events (And Why You Must)
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
| Cause | Explanation |
|---|---|
| Slow server response | Shopify times out and retries. |
| Network timeout mid‑request | The request never reaches your handler fully. |
| Server restart while processing | In‑flight request is lost, triggering a retry. |
| Queue consumer crash | The 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
| Event | Risk | Fix |
|---|---|---|
orders/paid | Double fulfillment | DB unique constraint on dedup key |
inventory_levels/update | Wrong stock count | Use absolute values instead of deltas |
refunds/create | Double refund | Check refund ID before issuing |
customers/create | Duplicate accounts | Enforce 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
- Fast ACK → 2xx response to Shopify.
- Queue → Background job handles business logic.
- Redis dedup → Quick detection of repeats.
- DB unique constraint → Guarantees eventual consistency.
- 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.