If you build anything on top of Shopify webhooks, you will eventually receive the same event twice. Maybe three times. Sometimes more. This is not a bug in your code or a flaw in Shopify. It is a fundamental property of distributed systems, and Shopify is explicit about it: webhook delivery is “at least once,” never “exactly once.”
That single design decision shapes how you must build your consumers. If you treat every incoming webhook as a unique, one-time truth, you will double-charge customers, create duplicate orders in your ERP, send two shipping confirmations, and slowly corrupt your data. In this post we will dig into why duplicate Shopify webhook events happen, how to detect them, and how to build systems that shrug them off without breaking.
Why Shopify Sends Duplicate Webhooks
Shopify guarantees that a webhook will be delivered at least once. To keep that promise across an unreliable network, it has to retry. And retries are where duplicates are born.
Here are the most common causes.
Network timeouts. Shopify fires a webhook, your server processes it fully, but the acknowledgement response is lost or arrives after Shopify’s timeout window. Shopify assumes failure and retries. You now process the same payload twice.
Slow responses. Shopify expects a 200 response within a few seconds. If your handler does heavy work inline and responds late, Shopify may have already queued a retry.
Infrastructure hiccups. Load balancer resets, pod restarts during a deploy, or a brief database lock can all cause a request to fail after partial work has been done.
Shopify-side retries. Even without any failure on your side, Shopify’s own retry logic can occasionally send an event more than once during platform-level events.
The takeaway is simple. You cannot prevent duplicates at the source. You can only design to absorb them. This is closely tied to broader challenges around building reliable Shopify webhook consumers and dealing with webhook ordering problems.
The Cost of Ignoring Duplicates
Before we get into solutions, it helps to picture what failure looks like.
| Event type | What a duplicate does if unhandled |
|---|---|
orders/create |
Two orders in your ERP, duplicate fulfillment requests |
orders/paid |
Customer billed twice in an external payment ledger |
refunds/create |
Double refund issued, real financial loss |
inventory_levels/update |
Stock count corrupted, overselling risk |
customers/create |
Duplicate CRM records, broken segmentation |
fulfillments/create |
Two shipping labels, two tracking emails |
Every one of these is a real incident that teams have shipped to production. The financial and trust cost is significant, which is why incident response planning should include a clear duplicate-event playbook.
Idempotency Is the Core Idea
The single most important concept in duplicate event handling is idempotency. An operation is idempotent if running it once produces the same result as running it ten times.
Think of a light switch set to “on.” Flip it to on once, it is on. Flip it to on again, it is still on. No change. That is the behavior you want from your webhook handlers.
When your processing is idempotent, duplicates stop being a threat. The second, third, and fourth delivery of the same event simply have no additional effect. You can read a deeper treatment of this in our guide to idempotency strategies in Shopify systems, but the practical patterns below will get you most of the way there.
Pattern 1: Deduplicate by Event ID
Every Shopify webhook delivery carries a unique identifier in the X-Shopify-Webhook-Id header. This value stays the same across retries of the same event. That makes it the cleanest deduplication key you have.
The core approach is a store of processed IDs. Before you do any work, check whether you have seen the ID. If you have, skip. If you have not, record it and proceed.
<?php
function handleWebhook($request) {
$webhookId = $request->header('X-Shopify-Webhook-Id');
// Atomic insert: succeeds only if the ID is new
$inserted = DB::table('processed_webhooks')->insertOrIgnore([
'webhook_id' => $webhookId,
'received_at' => now(),
]);
if (!$inserted) {
// Already processed this event, acknowledge and exit
return response('Duplicate ignored', 200);
}
processEvent($request->payload());
return response('OK', 200);
}
The key detail is the atomic insert. You must make the “check and record” step a single atomic operation. If you check first and insert later in two separate steps, two concurrent duplicate requests can both pass the check before either inserts. That race condition reintroduces the exact bug you are trying to kill. We cover this class of problem in depth in preventing race conditions in Shopify order processing.
Use a unique constraint on the webhook_id column so the database enforces uniqueness even under heavy concurrency.
CREATE TABLE processed_webhooks (
webhook_id VARCHAR(255) PRIMARY KEY,
received_at TIMESTAMP NOT NULL
);
Pattern 2: Deduplicate by Resource State
The event ID approach handles exact retries. But sometimes you receive two logically identical events with different IDs, for example when an update fires twice because of rapid successive changes. For those cases, dedupe on resource state instead.
Shopify payloads include an updated_at timestamp on most resources. You can store the last processed timestamp for each resource and reject anything older or equal.
<?php
function processOrderUpdate($payload) {
$orderId = $payload['id'];
$incomingUpdatedAt = strtotime($payload['updated_at']);
$lastSeen = Cache::get("order_updated:{$orderId}");
if ($lastSeen !== null && $incomingUpdatedAt <= $lastSeen) {
// This update is stale or duplicate, skip it
return;
}
applyOrderUpdate($payload);
Cache::put("order_updated:{$orderId}", $incomingUpdatedAt);
}
This pattern also protects you against out-of-order delivery, which pairs well with strategies for handling eventual consistency in Shopify integrations. An older event arriving after a newer one gets rejected on the same rule.
Pattern 3: Make the Operation Naturally Idempotent
The most robust defense is to design operations that do not care how many times they run. This beats any dedup store because it needs no extra bookkeeping.
A few concrete techniques.
Upserts instead of inserts. When syncing a customer or product, use an upsert keyed on the Shopify ID. Running it twice updates the same row rather than creating a duplicate.
INSERT INTO customers (shopify_id, email, name) VALUES (?, ?, ?) ON CONFLICT (shopify_id) DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name;
Set absolute values, not deltas. If a webhook says inventory is now 42, set it to 42. Do not increment by a delta, because a replayed delta compounds the error.
Use external idempotency keys. When you call downstream APIs such as a payment processor, pass an idempotency key derived from the Shopify event. Many providers will then dedupe on their side too.
These habits make your whole pipeline forgiving, which matters when you are also managing CRM synchronization or a large product catalog sync.
Where Deduplication Should Live in Your Architecture
A common mistake is doing dedup work inline in the HTTP handler and then doing the heavy processing there too. That slows your response, which ironically triggers more retries and more duplicates.
The better pattern is to split acknowledgement from processing. Accept the webhook fast, verify it, write it to a queue, and respond 200 immediately. Do the real work asynchronously.
<?php
function receiveWebhook($request) {
verifyHmac($request); // reject forged requests early
$webhookId = $request->header('X-Shopify-Webhook-Id');
Queue::push('process_shopify_event', [
'webhook_id' => $webhookId,
'topic' => $request->header('X-Shopify-Topic'),
'payload' => $request->getContent(),
]);
return response('OK', 200);
}
With this shape you can dedupe at two points: at the queue boundary and at the final write. This is the foundation of queue-based Shopify webhook processing and the broader approach described in our async event processing architectures. The faster you respond, the fewer retries Shopify sends in the first place.
Always verify the HMAC signature before trusting a payload. Skipping that step opens you to forged events, and our guide on secure webhook verification systems walks through doing it correctly.
Choosing a Deduplication Store
Where you keep your processed-event records affects performance and reliability. Here is how the common options compare.
| Store | Strengths | Trade-offs |
|---|---|---|
| Relational DB table | Durable, strong uniqueness guarantees | Slower under extreme write volume |
| Redis with TTL | Very fast, simple | Volatile, needs careful persistence config |
| Redis + DB hybrid | Fast reads, durable backstop | More moving parts |
| Message queue dedup | Built into some brokers | Window-limited, broker-specific |
For most Shopify apps, a relational table with a unique constraint is the safe default. Add Redis in front once write volume climbs. During peak traffic such as flash sales, the hybrid model keeps latency low while preserving correctness, and it complements the techniques in scaling Shopify webhook infrastructure.
How Long to Keep Dedup Records
You do not need to store processed webhook IDs forever. Shopify retries over a bounded window, so after that window closes a replayed event will not arrive.
A practical rule is to retain records for 48 to 72 hours, comfortably longer than Shopify’s retry period. A TTL in Redis or a scheduled cleanup job on your table keeps the store from growing without bound.
Be careful if you run a webhook replay system that intentionally reprocesses old events. In that case your dedup keys should account for replay intent so that a deliberate replay is not silently swallowed as a duplicate.
Handling Duplicates That Slip Through
No system is perfect. Some duplicates will reach your core logic despite your best efforts. Plan for that.
Reconciliation jobs. Run scheduled checks that look for duplicate records, for example two orders with the same Shopify order ID, and flag or merge them. This is your safety net against data drift.
Dead letter handling. When an event repeatedly fails, route it to a dead letter queue for inspection rather than retrying forever. Our post on dead letter queues for Shopify webhooks covers the full pattern.
Alerting. If your duplicate rate suddenly spikes, something upstream changed. Good webhook monitoring and observability turns an invisible problem into an actionable alert.
A Complete Handler, Start to Finish
Putting the pieces together, a resilient handler looks like this in pseudocode.
<?php
// 1. Receive and verify fast
function receive($request) {
verifyHmac($request);
$id = $request->header('X-Shopify-Webhook-Id');
Queue::push('process_event', [
'id' => $id,
'payload' => $request->getContent(),
]);
return response('OK', 200);
}
// 2. Process asynchronously with dedup
function processEvent($job) {
$inserted = DB::table('processed_webhooks')
->insertOrIgnore(['webhook_id' => $job['id'], 'received_at' => now()]);
if (!$inserted) {
return; // duplicate, already handled
}
$payload = json_decode($job['payload'], true);
// 3. Idempotent write
DB::table('orders')->updateOrInsert(
['shopify_id' => $payload['id']],
['total' => $payload['total_price'], 'updated_at' => now()]
);
}
Three layers of defense: fast verified acknowledgement, event-ID deduplication, and an idempotent upsert at the end. Any one of them alone reduces risk. Together they make duplicate delivery a non-event.
Scaling the Approach
As your store grows, the dedup store itself becomes a hot path. Every webhook hits it. If you are heading toward millions of requests, think about sharding the dedup table by resource type or using a distributed cache.
Backpressure matters too. If downstream systems slow down, your queue grows and retries pile up. Managing that gracefully is the subject of backpressure handling in Shopify systems, and it ties directly into avoiding cascading failures when one component starts to struggle.
For teams running large volumes, an event streaming architecture can centralize deduplication across many consumers rather than solving it per service.
Key Takeaways
Duplicate webhook events are guaranteed, not exceptional. Design for them from day one.
Dedupe on the X-Shopify-Webhook-Id header using an atomic, database-enforced write. Layer on resource-state checks with updated_at for logical duplicates. Wherever possible, make the final operation idempotent through upserts and absolute-value writes so duplicates simply have no effect.
Split acknowledgement from processing so you respond fast and trigger fewer retries. Keep dedup records for a few days, run reconciliation jobs as a safety net, and monitor your duplicate rate so surprises become alerts.
Get these patterns right and your integration stays correct no matter how many times Shopify knocks on your door with the same message.
Frequently Asked Questions
Why does Shopify send duplicate webhook events?
Shopify guarantees at-least-once delivery, so it retries when it does not receive a timely 200 response. Network timeouts, slow handlers, and infrastructure hiccups all cause retries, which arrive as duplicate events with the same webhook ID.
What header identifies a duplicate Shopify webhook?
The X-Shopify-Webhook-Id header carries a unique ID that stays constant across retries of the same event. Storing and checking this ID is the most reliable way to detect exact duplicates.
What does idempotent webhook handling mean?
An idempotent handler produces the same result whether an event is processed once or many times. Using upserts and setting absolute values instead of deltas makes operations naturally safe against duplicates.
How long should I store processed webhook IDs?
Keep them for 48 to 72 hours, which comfortably exceeds Shopify’s retry window. A TTL or scheduled cleanup job prevents the dedup store from growing indefinitely.
Can deduplication slow down my webhook responses?
It can if you do it inline with heavy processing. Accept and verify the webhook quickly, push it to a queue, respond 200, and run deduplication and processing asynchronously to keep response times low.
What if a duplicate still gets through my checks?
Run reconciliation jobs that detect and merge duplicate records, route repeatedly failing events to a dead letter queue, and alert on unusual duplicate rates so you can respond before data corruption spreads.
