How to recover missed Shopify orders after a webhook failure

If your webhook endpoint was down for more than ~4 hours, Shopify's permanently dropped some orders. Here's how to find them and put them back into your system.

Quick answer Pull every order created in the outage window from the Shopify Admin API using a bulk operation. Compare the order IDs to what you have locally. For each missing ID, fetch the full record, construct a webhook-shaped payload, sign it with your client secret, and POST it to your webhook URL. Tag the synthesized events so audits can distinguish them from real-time deliveries.

Symptoms

  • Customer service is dealing with "where's my order" tickets that match a recent outage window.
  • The Admin API shows orders your DB doesn't have.
  • Your fulfillment / accounting / inventory system has a gap that lines up with a deploy or an incident.
  • You discover the gap retrospectively during a quarterly audit and don't yet know how big it is.

Recovery — step by step

Step 1: Bound the outage window

You need two timestamps:

  1. When did webhooks stop reaching your system? Look at the timestamp of the last successfully-processed event. If you log X-Shopify-Triggered-At, that's the firing time on Shopify's side. Otherwise use your received_at minus a safety margin.
  2. When did webhooks resume? The first event you processed after the gap.

Pad both ends by 30–60 minutes. Clock drift between Shopify and your servers, plus retries that arrive at the edge of the window, can leak events into either side.

Step 2: Pull every relevant order from the Admin API

For windows under a few thousand records, paginated GraphQL works:

query getOrders($cursor: String) {
  orders(
    first: 250,
    after: $cursor,
    query: "created_at:>='2026-05-09T02:00:00Z' created_at:<'2026-05-09T05:00:00Z'"
  ) {
    edges {
      node {
        id
        legacyResourceId
        createdAt
        updatedAt
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}

For larger windows (hours-long outages on a high-volume shop), use a bulk operation:

mutation {
  bulkOperationRunQuery(
    query: """
    {
      orders(query: "created_at:>='2026-05-09T02:00:00Z' created_at:<'2026-05-12T00:00:00Z'") {
        edges {
          node {
            id legacyResourceId createdAt updatedAt
          }
        }
      }
    }
    """
  ) {
    bulkOperation { id status }
    userErrors { field message }
  }
}

Bulk operations don't count against the standard rate limits. The result is delivered as JSONL — one JSON object per line, streamable. Operations must complete within 10 days or they fail. On API version 2026-01 and higher, up to 5 bulk queries can run concurrently per shop. Poll currentBulkOperation (or subscribe to the bulk_operations/finish webhook) until status is COMPLETED, then download the result file from the returned URL.

Step 3: Diff against your local store

# Pseudocode
shopify_ids = bulk_result.map { |line| JSON.parse(line)["legacyResourceId"].to_s }.to_set
local_ids   = Order.where("created_at >= ? AND created_at <= ?", from, to).pluck(:shopify_id).to_set

missing_ids = shopify_ids - local_ids
puts "Missing #{missing_ids.size} orders"

Most missing orders cluster in the middle of the outage. A handful may be on the edges — events that retried during the recovery and arrived just after you resumed. Always check before synthesizing to avoid duplicate processing.

Step 4: Fetch full payloads for each missing order

The bulk fetch returned only the fields you asked for. Your webhook handler likely needs the full order shape. Fetch each missing order via the standard GraphQL Admin API:

query {
  order(id: "gid://shopify/Order/4321") {
    id
    legacyResourceId
    createdAt
    customer { id email firstName lastName }
    lineItems(first: 100) {
      edges {
        node { id name quantity variant { sku } }
      }
    }
    totalPriceSet { shopMoney { amount currencyCode } }
  }
}

Be mindful of rate limits here — these are normal queries, not bulk. Standard plans get 100 points/second restored; Advanced 200; Plus 1000.

Step 5: Synthesize and replay through your handler

# Ruby example
require "openssl"
require "base64"

payload = {
  id:           order["legacyResourceId"].to_i,
  order_number: order["name"].sub("#", "").to_i,
  email:        order["customer"]["email"],
  total_price:  order["totalPriceSet"]["shopMoney"]["amount"],
  line_items:   order["lineItems"]["edges"].map { |e| e["node"] }
}

raw_body = payload.to_json
hmac     = Base64.strict_encode64(
             OpenSSL::HMAC.digest("sha256", ENV["SHOPIFY_CLIENT_SECRET"], raw_body)
           )

HTTP.post(
  "https://your-app.com/shopify/webhooks",
  body: raw_body,
  headers: {
    "Content-Type"            => "application/json",
    "X-Shopify-Topic"         => "orders/create",
    "X-Shopify-Hmac-Sha256"   => hmac,
    "X-Shopify-Shop-Domain"   => "your-shop.myshopify.com",
    "X-Shopify-Event-Id"      => "reconciled-#{order['legacyResourceId']}",
    "X-Shopify-Api-Version"   => "2025-10",
    "X-OurApp-Reconciled"     => "1"
  }
)

The custom header (above: X-OurApp-Reconciled) is your audit hook. When the handler logs received_at, it can also log received_via as "reconciliation" vs "realtime". Six months from now when a customer asks why their order shows a 6-hour delay between placement and processing, you need that distinction.

Edge cases that don't reconcile cleanly

  • Inventory delta webhooks. inventory_levels/update reports a transition. The Admin API only shows current level — you can't reconstruct missed transitions.
  • App lifecycle webhooks. app/uninstalled has no recoverable Admin API equivalent. If you missed an uninstall, the next API call returns 401 — that's your signal to clean up.
  • Mandatory privacy webhooks. customers/data_request, customers/redact, shop/redact have a 30-day completion deadline and aren't reconcilable from the API. Track them through Shopify Partner records of affected shops.
  • Order edits and cancellations. orders/edited, orders/cancelled, orders/fulfilled describe state transitions. If you missed a sequence, you may need to query both the order and its events to reconstruct the timeline.

Prevention

  • Run hourly reconciliation against the Admin API for high-value topics like orders/create. Any gap is caught within an hour, not days later.
  • Keep your webhook handler under the 5-second response window. Async-process everything except HMAC verification and persistence.
  • Daily check on subscription health. Auto-re-register anything Shopify removed.
  • Audit-log every reconciliation run. How many records checked, how many gaps found, how long it took. When a customer asks "why?", you need timestamps.

Where HookRescue fits

This entire reconciliation playbook runs automatically every hour for every active source you connect to HookRescue. When we find a gap, we synthesize a webhook-shaped payload, sign it with your client secret, and forward it to your endpoint exactly like a fresh delivery. Your handler doesn't need to know it was a recovery. Every reconciliation run is logged: how many records checked, how many gaps found, how long it took.

The setup is one URL change in your Shopify webhook subscription. We re-sign every event with your secret so HMAC verification continues to work unchanged. Free during private beta — no credit card.

Related