DEV Community

Cover image for Shopify Webhooks Don't Arrive in Order - Here's How to Handle It
Muhammad Masad Ashraf
Muhammad Masad Ashraf

Posted on • Originally published at kolachitech.com

Shopify Webhooks Don't Arrive in Order - Here's How to Handle It

If you have ever built a Shopify integration and started seeing weird inventory counts, duplicate orders, or fulfillments firing before payment confirmation — welcome to the club.

The culprit is almost always the same: out-of-order webhook delivery.

Shopify does not guarantee the order in which webhook events arrive. That one sentence should be in every Shopify integration tutorial. It rarely is.

Let me break down why this happens and how to actually fix it.


Why Events Arrive Out of Order

Each webhook Shopify fires is an independent HTTP POST. No coordination. No sequence guarantee.

Here is what causes ordering to break:

  • Network latency — two requests take different paths, arrive in reverse
  • Retry logic — a failed event retried 30 minutes later arrives after newer events
  • Parallel workers — your consumer processes two events simultaneously; lighter one finishes first
  • FIFO queue trade-offs — standard queues (SQS, etc.) don't guarantee strict ordering

So you subscribe to orders/create and orders/updated. The update can genuinely arrive first. Your handler looks up the order, finds nothing, and either errors or skips. The order never enters your system.


The Damage This Causes

Here are the real scenarios I see teams hit:

1. Order Updated Before Created
Handler can't find the order. Skips silently. Order is now invisible to your system.

2. Fulfillment Before Payment
Your 3PL integration fires a pick request for an unpaid order. Policy violation at minimum. Financial exposure at worst.

3. Double Inventory Decrement
Flash sale. Rapid-fire inventory_levels/update events. Parallel consumers. Same decrement applies twice. Stock goes negative.

4. Cancellation Before Creation
orders/cancelled arrives. No record found. Discarded. Then orders/create arrives and creates an active order with no cancelled state.


The Fixes (Layered Approach)

No single fix solves this. You layer strategies based on your risk tolerance.

Fix 1: Timestamp Comparison

Before applying any event, compare its updated_at against your stored record:

if (incomingEvent.updated_at <= storedRecord.updated_at) {
  console.log('Stale event, discarding');
  return;
}
applyUpdate(incomingEvent);
Enter fullscreen mode Exit fullscreen mode

Simple. Works well for orders/updated and inventory_levels/update.


Fix 2: Idempotent Handlers

Every handler must be safe to run twice. Use the X-Shopify-Webhook-Id header to track processed events:

const webhookId = req.headers['x-shopify-webhook-id'];

const alreadyProcessed = await db.webhookLog.findOne({ webhookId });
if (alreadyProcessed) return res.sendStatus(200); // acknowledge but skip

await db.webhookLog.create({ webhookId });
await processEvent(payload);
Enter fullscreen mode Exit fullscreen mode

Always return 200 even when skipping. Shopify retries anything that isn't a 2xx.


Fix 3: State Machine for Orders

Model your order lifecycle explicitly. Reject invalid transitions:

const validTransitions = {
  null: ['created'],
  created: ['paid', 'cancelled'],
  paid: ['fulfilled', 'refunded', 'cancelled'],
  fulfilled: ['refunded'],
  cancelled: [],
};

function canTransition(currentState, newState) {
  return validTransitions[currentState]?.includes(newState) ?? false;
}
Enter fullscreen mode Exit fullscreen mode

If orders/fulfilled arrives and your order is still in created state, that is an invalid transition. Route it to a dead letter queue, not the bin.


Fix 4: FIFO Queue Per Resource ID

Route all webhook events into a FIFO queue. Use the resource ID (order ID, product ID) as the partition key.

Shopify --> Webhook endpoint (ACK immediately)
        --> SQS FIFO Queue (MessageGroupId = order_id)
             --> Consumer (processes one event per order at a time)
Enter fullscreen mode Exit fullscreen mode

This guarantees events for the same order process sequentially, not in parallel.


Fix 5: Fetch Before Acting

For critical operations, don't trust the payload alone. Fetch current state from the Shopify API before making changes:

async function handleFulfillment(payload) {
  const order = await shopify.order.get(payload.id);

  if (order.financial_status !== 'paid') {
    console.warn('Order not paid, skipping fulfillment');
    return;
  }

  await fulfillmentService.process(order);
}
Enter fullscreen mode Exit fullscreen mode

Costs one extra API call. Eliminates an entire class of stale-state bugs.


Fix 6: Dead Letter Queue

Never silently discard events you can't process. Route them to a DLQ:

try {
  await processWebhook(event);
} catch (err) {
  if (err.type === 'UNPROCESSABLE') {
    await dlq.send(event);
  } else {
    throw err; // let the queue retry
  }
}
Enter fullscreen mode Exit fullscreen mode

Review DLQ items daily. Most will be ordering artifacts you can replay manually.


Fix 7: Reconciliation Job

No ordering strategy is perfect. Run a scheduled job that compares your local state against Shopify's API:

// Runs every 30 minutes
async function reconcile() {
  const recentOrders = await shopify.order.list({ updated_at_min: thirtyMinutesAgo });

  for (const order of recentOrders) {
    const localOrder = await db.orders.findOne({ shopifyId: order.id });

    if (!localOrder || localOrder.status !== order.financial_status) {
      await syncOrder(order);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This is your safety net. It catches everything the real-time system misses.


The Full Architecture

Shopify
  --> Webhook Endpoint (ACK < 5s, no processing)
  --> FIFO Message Queue (partitioned by resource ID)
       --> Consumer Worker
            --> Dedup check (X-Shopify-Webhook-Id)
            --> Timestamp comparison (discard stale)
            --> State machine validation
            --> Fetch current state (for critical ops)
            --> Apply update
       --> Dead Letter Queue (unprocessable events)
  --> Reconciliation Job (every 30 min)
Enter fullscreen mode Exit fullscreen mode

This handles duplicate events, out-of-order events, lost events, and stale overwrites.


Quick Reference

Problem Fix
Update arrives before create State machine + DLQ for invalid transitions
Duplicate events Idempotency via X-Shopify-Webhook-Id
Stale overwrites Timestamp comparison before applying
Parallel worker conflicts FIFO queue per resource ID
Unresolvable ordering Dead letter queue + manual review
Missed events Reconciliation job

TL;DR

  • Shopify does not guarantee webhook event order
  • Network latency, retries, and parallel workers all cause out-of-order delivery
  • Layer your defenses: timestamps, idempotency, state machines, FIFO queues, DLQ, reconciliation
  • Always acknowledge webhooks immediately and process asynchronously
  • Never silently discard events you can't handle

Build for out-of-order delivery from day one and you will save yourself a lot of production debugging.


Have you hit webhook ordering bugs in production? Drop your war story in the comments.

Read More

Top comments (0)