You built a Shopify integration. Orders come in, webhooks fire, and your system processes them. Then one day, you notice an order was fulfilled twice. Or a customer got charged twice. Or your inventory went negative.
The culprit? Duplicate Shopify webhook events.
This is not a bug in your code. It is how webhook delivery systems work. Shopify guarantees at-least-once delivery, not exactly-once. That means your endpoint will receive the same event more than once, especially during network timeouts, slow responses, or infrastructure hiccups.
This guide shows you exactly how to detect, prevent, and handle duplicate Shopify webhook events without breaking your store’s operations.
1. Why Shopify Sends Duplicate Webhook Events
Shopify uses an HTTP-based webhook system. When an event triggers (like orders/paid), Shopify fires a POST request to your endpoint. If your server does not return a 2xx response within 5 seconds, Shopify retries the request.
Shopify retries up to 19 times over 48 hours using an exponential backoff strategy.
Here is when duplicates commonly occur:
| Trigger | What Happens |
|---|---|
| Slow server response | Shopify retries before you reply |
| Network timeout | Request sent twice, both arrive |
| Server restart mid-processing | Event processed partially, retried |
| Queue consumer crashes | Event pulled again from queue |
| Shopify infrastructure hiccup | Event fired more than once internally |
To learn more about how the retry mechanism works, read our guide on Shopify webhook retry strategies.
2. Real-World Consequences of Ignoring Duplicates
Ignoring duplicate event handling leads to serious business problems. Here are the most common ones:
Double fulfillment – An order ships twice. You lose product and pay double shipping.
Duplicate charges – Payment webhooks trigger two charge attempts. Customers complain and file disputes.
Inventory corruption – Stock deducted twice. Items go negative. Overselling begins.
Duplicate emails – Customers receive the same confirmation email two or three times.
Bad analytics – Revenue reports inflate. Decisions get made on wrong data.
These are not edge cases. High-volume Shopify stores with custom integrations hit these issues regularly. If you are building anything beyond basic Shopify flows, understanding fault-tolerant Shopify integration is essential from day one.
3. How to Identify a Duplicate Event
Every Shopify webhook payload includes identifiers you can use for deduplication. You need to understand two key fields:
The X-Shopify-Webhook-Id Header
Shopify sends a unique X-Shopify-Webhook-Id header with every webhook delivery attempt. This ID changes on each retry. It identifies the delivery attempt, not the event itself.
X-Shopify-Webhook-Id: a8f3d2e1-1234-5678-abcd-ef0123456789
Do not use this for deduplication on its own. It will treat retries as new events.
The Resource ID in the Payload
The actual order ID, product ID, or customer ID inside the payload payload stays the same across retries. This is your true deduplication key.
{
"id": 5678901234,
"event": "orders/paid",
"created_at": "2026-05-25T10:00:00Z"
}
The Best Deduplication Key
Combine the event topic and the resource ID:
dedup_key = "orders/paid:5678901234"
This uniquely identifies the business event, not the delivery attempt. Use this key to check if you have already processed this event before acting on it.
4. Core Strategies for Shopify Deduplication
There are three main approaches to deduplication. Each has tradeoffs.
| Strategy | Best For | Tradeoff |
|---|---|---|
| In-memory cache (Redis) | High-volume, low-latency apps | Lost on restart unless persisted |
| Database unique constraint | Guaranteed correctness | Slower, needs DB write per event |
| Idempotent business logic | Simple integrations | Harder to build, no central store |
Most production systems use a combination. A fast Redis check at the entry point, backed by a database constraint for safety.
5. Building Idempotent Webhooks Step by Step
An idempotent webhook handler produces the same result no matter how many times it runs with the same input. This is the foundation of safe webhook processing.
Step 1: Respond Fast
Return 200 OK immediately after receiving the request. Do your processing asynchronously. This prevents Shopify from retrying due to slow responses.
app.post('/webhooks/orders-paid', async (req, res) => {
res.status(200).send('OK'); // Respond immediately
await queue.push({ topic: 'orders/paid', payload: req.body });
});
Step 2: Build a Seen-Events Store
Use Redis or your database to store processed event keys with a TTL.
const dedupKey = `orders/paid:${payload.id}`;
const alreadySeen = await redis.get(dedupKey);
if (alreadySeen) {
console.log('Duplicate event, skipping:', dedupKey);
return;
}
await redis.setex(dedupKey, 86400, '1'); // 24-hour TTL
Step 3: Process and Commit Atomically
After the dedup check passes, process the event and mark it as done in the same transaction where possible.
await db.transaction(async (trx) => {
await trx('processed_events').insert({ dedup_key: dedupKey });
await fulfillOrder(payload.id, trx);
});
If the transaction fails, the event is not marked as processed. Shopify retries it. Your dedup check catches the retry before double-processing.
This approach aligns with the principles covered in our deep dive on idempotency strategies in Shopify systems.
6. Deduplication at the Queue Level
Many teams push webhook payloads into a queue (SQS, RabbitMQ, BullMQ) for async processing. Deduplication at this layer reduces duplicates before they even reach your business logic.
SQS Message Deduplication
AWS SQS FIFO queues support native deduplication. Set the MessageDeduplicationId to your event key.
await sqs.sendMessage({
QueueUrl: process.env.QUEUE_URL,
MessageBody: JSON.stringify(payload),
MessageGroupId: 'shopify-orders',
MessageDeduplicationId: `orders-paid-${payload.id}`
}).promise();
SQS deduplicates messages with the same ID within a 5-minute window. For events that retry hours later, you still need application-level deduplication.
BullMQ Job Deduplication
await queue.add('process-order', payload, {
jobId: `orders-paid-${payload.id}`, // Unique job ID prevents duplicates
attempts: 3,
backoff: { type: 'exponential', delay: 1000 }
});
Setting a custom jobId makes BullMQ skip adding the job if it already exists in the queue.
For a complete look at queue infrastructure patterns, see our guide on queue-based Shopify webhook processing.
7. Database-Level Protection
The database is your last line of defense. Even if your application-level dedup fails, a unique constraint stops duplicate writes.
Create a Processed Events Table
CREATE TABLE processed_webhook_events ( id SERIAL PRIMARY KEY, dedup_key VARCHAR(255) UNIQUE NOT NULL, event_topic VARCHAR(100) NOT NULL, resource_id BIGINT NOT NULL, processed_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP ); CREATE INDEX idx_dedup_key ON processed_webhook_events(dedup_key); CREATE INDEX idx_expires_at ON processed_webhook_events(expires_at);
Insert with Conflict Handling
const result = await db.raw(`
INSERT INTO processed_webhook_events (dedup_key, event_topic, resource_id)
VALUES (?, ?, ?)
ON CONFLICT (dedup_key) DO NOTHING
RETURNING id
`, [dedupKey, topic, resourceId]);
if (result.rows.length === 0) {
// Already processed, skip
return;
}
The ON CONFLICT DO NOTHING clause is atomic. Even under concurrent requests for the same event, only one insert succeeds. The other returns no rows and knows to skip.
Clean Up Old Records
Run a periodic job to delete old processed event records. You do not need to keep them forever.
DELETE FROM processed_webhook_events WHERE expires_at < NOW();
This pattern also works well within a multi-service Shopify architecture where multiple services may receive the same event.
8. Handling Specific High-Risk Events
Some events carry much higher risk than others when duplicated. Treat these with extra care.
| Event Topic | Duplicate Risk | Recommended Guard |
|---|---|---|
orders/paid |
Double fulfillment, double charge | DB unique constraint + idempotent fulfillment API |
orders/create |
Duplicate order records | Dedup on order ID before insertion |
inventory_levels/update |
Wrong stock counts | Use absolute values, not relative increments |
customers/create |
Duplicate customer accounts | Check email uniqueness before insert |
refunds/create |
Double refund issued | Check refund ID exists before processing |
For inventory in particular, always prefer setting absolute quantities over incrementing or decrementing. This makes your handler naturally idempotent regardless of how many times it runs.
This is especially important in distributed Shopify inventory sync scenarios where the same update may arrive from multiple paths.
9. Testing Your Deduplication Logic
Do not assume your dedup works. Test it deliberately.
Unit Test: Duplicate Suppression
test('skips processing if event already seen', async () => {
const payload = { id: 12345, topic: 'orders/paid' };
await processWebhook(payload); // First call
const result = await processWebhook(payload); // Duplicate call
expect(result.skipped).toBe(true);
expect(fulfillOrder).toHaveBeenCalledTimes(1); // Only once
});
Load Test: Concurrent Duplicates
Send the same webhook payload 10 times simultaneously using a tool like k6 or Artillery. Verify your database has only one processed record and the business action ran exactly once.
# Simulate 10 concurrent duplicate requests
for i in {1..10}; do
curl -X POST https://your-app.com/webhooks/orders-paid \
-H "Content-Type: application/json" \
-d '{"id": 99999, "topic": "orders/paid"}' &
done
wait
Check your processed_webhook_events table. You should see exactly one row for orders/paid:99999.
Building reliable Shopify webhook consumers means this test passes every time, not just most of the time.
10. Monitoring and Alerting
Build visibility into your deduplication layer. Without it, you fly blind.
Metrics to Track
| Metric | What It Tells You |
|---|---|
webhook.received |
Total events incoming |
webhook.duplicate |
Events skipped as duplicates |
webhook.processed |
Events actually handled |
webhook.dedup_ratio |
Duplicate rate (%) |
webhook.processing_time |
Latency of handler |
Alert on Anomalies
Set up alerts when:
- Duplicate rate exceeds 10% (retry storm or replay happening)
- Processing time exceeds 4 seconds (risk of Shopify retrying)
- Any uncaught exception in webhook handler
- Dead letter queue depth rises
If your system uses a dead letter queue for failed webhooks, make sure duplicate protection still applies when you replay those events. Read our guide on dead letter queue handling for Shopify webhooks for the full pattern.
11. Deduplication in Event-Driven Architectures
If you use an event-driven architecture for your Shopify app, deduplication becomes even more critical. Events can travel through multiple services and queues before reaching their final destination.
Apply the dedup check at the consumer level, not just the entry point. Each service that acts on the event should verify it has not processed that event ID before.
This prevents scenarios where:
- Service A processes the event
- Service B receives a duplicate and also processes it
- Both services trigger downstream effects
Our breakdown of event-driven architecture for Shopify apps covers these multi-consumer scenarios in detail.
Also, if you are building at scale and need to handle millions of requests, the dedup store (usually Redis) needs careful capacity planning. See our guide on scaling Shopify apps to millions of requests for infrastructure considerations.
12. Summary Checklist
Use this before shipping any Shopify webhook integration:
- Webhook endpoint responds with
200 OKwithin 5 seconds - Processing happens asynchronously after the response
- Deduplication key combines event topic and resource ID
- Redis or in-memory cache checks duplicates at entry point
- Database unique constraint provides backup dedup protection
-
ON CONFLICT DO NOTHINGused for atomic inserts - Inventory updates use absolute values, not increments
- Dead letter queue captures failed events
- Load test with concurrent duplicate requests passes
- Monitoring tracks dedup ratio and processing latency
- Old processed event records cleaned up periodically
Frequently Asked Questions
Q1: Does Shopify send the same webhook ID for retries? No. Shopify sends a new X-Shopify-Webhook-Id header on each delivery attempt. Use the resource ID from the payload body combined with the event topic as your dedup key, not the header.
Q2: How long should I keep processed event records? Keep them for at least 48 hours, since Shopify retries for up to 48 hours. Many teams keep records for 7 days to cover edge cases and make debugging easier.
Q3: What is the difference between idempotent webhooks and deduplication? Deduplication detects and skips duplicate events. Idempotency means your handler produces the same result even if it runs multiple times. Ideally, you want both: dedup for efficiency and idempotent logic as a safety net.
Q4: Can I rely on Shopify not sending duplicates? No. Shopify guarantees at-least-once delivery. You must build deduplication into your application. This is a standard requirement, not an optional enhancement.
Q5: What happens if my dedup store (Redis) goes down? Your database unique constraint acts as the fallback. This is why the two-layer approach matters. Redis handles speed; the database handles correctness.
Q6: How do I handle duplicate events across multiple app instances? Use a shared dedup store (Redis cluster or your database) accessible by all instances. Do not use local in-memory stores for distributed systems, as each instance will have its own state.
