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
| Behaviour | What Shopify documents |
|---|---|
| Delivery | Not guaranteed. Shopify recommends reconciliation jobs that fetch data periodically. |
| Order | Not guaranteed within a topic or across topics. A products/update can arrive before products/create. |
| Duplicates | Possible, for example after a timeout or a retry |
| Response time | 1 second to connect and 5 seconds for the whole request. Only a 2xx status counts as success, and 3xx is treated as an error. |
| Retries | Up to 8 times over 4 hours |
| Removal | After 8 consecutive failures, subscriptions created through the Admin API are deleted. Subscriptions defined in shopify.app.toml aren't. |
| Delays | Deliveries 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:
[webhooks]
api_version = "2026-10"
[[webhooks.subscriptions]]
topics = ["orders/create", "orders/updated"]
uri = "/webhooks/orders"
name = "order-sync"
- A relative
urikeeps working in development, where the tunnel URL changes every time you runshopify app dev. filterandinclude_fieldscut 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.
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:
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:
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:
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-versionto test a new version against your handler before you changeapi_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/redactandshop/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.



