Web/Guru

Shopify Webhooks You Can Trust: Verification, Duplicates, Retries and Reconciliation

Shopify doesn't guarantee webhook delivery or order. How to verify, deduplicate and store deliveries, skip stale updates and reconcile, with code.

Published
Updated
October 5, 2026
Read time
8 min read
Written by
Webguru
Shopify Webhooks You Can Trust: Verification, Duplicates, Retries and Reconciliation
/On this page01 / 09

Shopify webhooks are the fastest way to keep an app, ERP (such as Business Central or NetSuite) or warehouse system in sync with a store. They're also easy to get subtly wrong. Shopify's own documentation says your app shouldn't rely on webhooks alone: delivery isn't guaranteed, order isn't guaranteed, and the same event can arrive twice. This guide turns those rules into a handler you can trust, with code.

What Shopify does and doesn't guarantee

BehaviourWhat Shopify documents
DeliveryNot guaranteed. Shopify recommends reconciliation jobs that fetch data periodically.
OrderNot guaranteed within a topic or across topics. A products/update can arrive before products/create.
DuplicatesPossible, for example after a timeout or a retry
Response time1 second to connect and 5 seconds for the whole request. Only a 2xx status counts as success, and 3xx is treated as an error.
RetriesUp to 8 times over 4 hours
RemovalAfter 8 consecutive failures, subscriptions created through the Admin API are deleted. Subscriptions defined in shopify.app.toml aren't.
DelaysDeliveries can arrive late, up to a day in some cases

Everything below follows from these seven rows.

1. Subscribe in your app configuration

Shopify recommends app-specific subscriptions in shopify.app.toml. They apply to every store that installs the app, and Shopify doesn't delete them when deliveries fail:

toml
[webhooks]
api_version = "2026-10"
[[webhooks.subscriptions]]
topics = ["orders/create", "orders/updated"]
uri = "/webhooks/orders"
name = "order-sync"
  • A relative uri keeps working in development, where the tunnel URL changes every time you run shopify app dev.
  • filter and include_fields cut the number and size of deliveries, which means less to process and fewer chances to time out.
  • For high volume, Shopify recommends Google Cloud Pub/Sub, or you can use Amazon EventBridge. Both remove most of the HTTPS concerns below.

2. Verify the HMAC on the raw body

Every HTTPS delivery carries an X-Shopify-Hmac-Sha256 header: a base64 HMAC-SHA256 of the raw request body, signed with your app's client secret. Verify it before trusting anything in the payload. The most common bug is a JSON body parser running first, which changes the bytes you're hashing.

js
import crypto from "node:crypto";
import express from "express";
const app = express();
function isValidShopifyHmac(rawBody, header, secrets) {
  const received = Buffer.from(header || "", "base64");
  return secrets.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}
// express.raw keeps the body as bytes; don't run express.json() on this route first
app.post("/webhooks/orders", express.raw({ type: "application/json" }), async (req, res) => {
  // During a client secret rotation, Shopify can take up to an hour to sign with the new secret
  const secrets = [process.env.SHOPIFY_API_SECRET, process.env.SHOPIFY_API_SECRET_PREVIOUS].filter(Boolean);
  if (!isValidShopifyHmac(req.body, req.get("X-Shopify-Hmac-Sha256"), secrets)) {
    return res.sendStatus(401);
  }
  await saveDelivery(req); // step 3
  res.sendStatus(200);
});

If you build on Shopify's React Router app template, authenticate.webhook(request) does the verification for you. Keep the webhook route outside the app's layout route.

3. Store first, answer fast, process later

Shopify gives you five seconds for the whole request. Don't call other APIs or write to your ERP inside the handler. Save the delivery and return 200. A unique key on X-Shopify-Webhook-Id turns a repeat delivery into a no-op:

js
async function saveDelivery(req) {
  await db.query(
    `INSERT INTO webhook_inbox (webhook_id, event_id, topic, shop, triggered_at, payload)
     VALUES ($1, $2, $3, $4, $5, $6)
     ON CONFLICT (webhook_id) DO NOTHING`,
    [
      req.get("X-Shopify-Webhook-Id"),
      req.get("X-Shopify-Event-Id"),
      req.get("X-Shopify-Topic"),
      req.get("X-Shopify-Shop-Domain"),
      req.get("X-Shopify-Triggered-At"),
      req.body.toString("utf8"),
    ]
  );
}

A worker then picks up unprocessed rows. If the worker crashes, the row is still there. Workers like this call the Admin API without a merchant present, so public apps need them ready for expiring offline access tokens from January 1, 2027. If you have two subscriptions to the same topic, each delivery has its own X-Shopify-Webhook-Id but they share an X-Shopify-Event-Id, which ties them to the same merchant action.

4. Process idempotently, and ignore stale updates

Because order isn't guaranteed, an older update can arrive after a newer one. Compare timestamps before you write, so a late delivery can't overwrite fresher data:

js
async function processOrderRow(row) {
  const order = JSON.parse(row.payload);
  const existing = await db.orders.findByGid(order.admin_graphql_api_id);
  if (existing && new Date(existing.updatedAt) >= new Date(order.updated_at)) {
    return markProcessed(row, "stale"); // we already have newer data
  }
  await db.orders.upsert(order);
  await markProcessed(row, "applied");
}

The same function should be safe to run twice on the same row. Upserts keyed on the Shopify ID are the simplest way to get there.

5. Reconcile on a schedule

Webhooks are the fast path. Reconciliation is the safety net. On a schedule, for example every 15 minutes, ask Shopify what changed since the last successful run, with a few minutes of overlap, and send the results through the same upsert:

graphql
query OrdersChangedSince($query: String!, $cursor: String) {
  orders(first: 100, after: $cursor, query: $query, sortKey: UPDATED_AT) {
    pageInfo { hasNextPage endCursor }
    nodes { id updatedAt displayFinancialStatus displayFulfillmentStatus cancelledAt }
  }
}

Pass a query such as updated_at:>'2026-09-23T08:45:00Z', and save the new checkpoint only after the whole run succeeds. Two limits are worth knowing. Apps can read only the last 60 days of orders unless they're granted read_all_orders, and for large backfills, bulk operations are a better fit than paging (see our REST to GraphQL guide).

6. Test and monitor

  • Trigger test deliveries with shopify app webhook trigger. Pass --api-version to test a new version against your handler before you change api_version.
  • Watch the Logs in the Dev Dashboard. Shopify flags failure rates above 0.5%, response times between four and five seconds, and removed subscriptions as the things to fix first.
  • Keep the emergency developer email monitored. Shopify sends failure warnings there.
  • Public apps in the App Store must also subscribe to the mandatory compliance topics: customers/data_request, customers/redact and shop/redact.

Checklist

  • Subscriptions in shopify.app.toml, with filters where they help
  • HMAC checked on the raw body, with both secrets accepted during a rotation
  • The handler stores the delivery and answers well inside five seconds
  • Duplicates dropped by X-Shopify-Webhook-Id
  • Stale updates ignored by comparing updated_at
  • A scheduled reconciliation job with a saved checkpoint
  • Alerts on failed deliveries and removed subscriptions

Need an integration that doesn't lose orders? See our Shopify API integration service. We audit existing webhook handlers and rebuild them along these lines. For app builds and wider platform work, our Shopify development team can help.

Sources

/Tags#Shopify#Webhooks#Integrations

Written by

Webguru

Shopify Development Team

Webguru.pk is a Shopify development team in Lahore, Pakistan, building stores, custom apps and integrations for brands worldwide.

/Share

Need a hand with this?

We design, build and fix this kind of work for Shopify, SaaS and AI products.

/faqs

Shopify Webhooks: Frequently Asked Questions

No. Shopify says webhook delivery isn't always guaranteed and recommends reconciliation jobs that periodically fetch data through the API, so your app stays consistent even when a delivery is missed or mishandled.

Make processing idempotent, or store each delivery's X-Shopify-Webhook-Id and skip IDs you've already seen. A unique database key on that ID makes a repeat delivery a no-op.

Shopify allows one second to connect and five seconds for the whole request, and only a 2xx response counts as success. Save the delivery and respond straight away, then do the real work in a background worker.

Shopify retries a failed delivery up to 8 times over 4 hours. After that it stops, so anything it couldn't deliver has to be recovered by a reconciliation job that fetches changed records through the API.

Subscriptions created through the Admin API are deleted after 8 consecutive failed deliveries, and Shopify sends a warning to the app's emergency developer email. Subscriptions defined in shopify.app.toml aren't deleted, which is one reason Shopify recommends them.

Losing orders between Shopify and your ERP?

We audit webhook handlers and add verification, deduplication and reconciliation so nothing goes missing.

or write to+44 7882 755642