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 });
});
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}`;
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
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()
);
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
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 });
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)