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 OK within 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 NOTHING used 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.

Your Trusted Shopify Partner.

Get in touch with our expert Shopify consultants today and let’s discuss your ideas and business requirements.