Shopify webhook retries work like this: your endpoint has five seconds to return a 2xx, Shopify retries a failed delivery a handful of times over a few hours, and if the failures keep coming it deletes the subscription. Retries also mean duplicates, so a handler that survives in production acknowledges first, dedupes on X-Shopify-Webhook-Id, does its work off the request thread, and runs a reconciliation job for the events that never arrive at all.
How many times does Shopify retry a webhook?
The troubleshooting guide is the page with the numbers. A delivery fails if your app does not respond within five seconds, and any 200-series status counts as success. As of October 2026 it says Shopify “retries failed webhook calls up to eight times in a four-hour period”, and that if failures persist, the subscription is removed.
Two things follow from that.
First, five seconds is the budget for the whole round trip, including TLS, your framework’s routing and whatever your handler does before it returns. A handler that calls the Admin API, writes three rows and sends an email before responding is going to time out under load, and every timeout is a retry you have to dedupe later.
Second, removal is silent from the app’s point of view. Your code does not get an error; the subscription is simply gone, and the next orders/create goes nowhere. That is why Shopify’s own best-practices page says plainly that “your app shouldn’t rely on receiving data from Shopify webhooks” and tells you to run reconciliation jobs. A webhook is the fast path; the Admin API, queried on a schedule, is what you trust.
Why does Shopify send the same webhook twice?
Because retries are at-least-once. If your endpoint processed the event but the 200 got lost to a network timeout, Shopify does not know that, so it sends the delivery again. The verification guide spells it out: “your app might receive the same webhook more than once, for example after a network timeout or a retry.”
There is a second source of duplicates. If you have two subscriptions on the same topic (say, one created by the app’s TOML config and one left behind by an older API call), each one gets its own delivery. The delivery structure reference describes the two headers that let you tell these cases apart:
X-Shopify-Webhook-Id is “a unique composite key per delivery”. A retry of the same delivery carries the same id, so this is the key you dedupe on.
X-Shopify-Event-Id is “a unique ID shared across all deliveries produced by the same merchant action”. Two subscriptions on one topic produce two webhook ids but one event id. Use it to correlate, not to dedupe, unless you really do want to collapse deliveries from separate subscriptions into one.
Ordering is the third thing not to assume. The best-practices page says Shopify “doesn’t guarantee ordering within a topic, or across different topics for the same resource”. A products/update can arrive before the products/create it logically follows. The page’s advice is to sequence on X-Shopify-Triggered-At or the payload’s updated_at, and to drop anything older than what you already hold.
What does an idempotent Shopify webhook handler look like?
In the Remix app template, authenticate.webhook(request) from @shopify/shopify-app-remix does the HMAC check and hands back a context with topic, shop, payload and webhookId, which the reference describes as the idempotency key. The handler below records the id, enqueues the work and returns. Nothing slow happens before the response.
// app/routes/webhooks.orders.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import { db } from "../db.server";
import { queue } from "../queue.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { topic, shop, payload, webhookId } = await authenticate.webhook(request);
// Insert the delivery id first. A unique constraint on webhookId makes
// the second delivery of the same id fail here, and we return 200 for it.
try {
await db.webhookDelivery.create({
data: { webhookId, topic, shop, receivedAt: new Date() },
});
} catch (error) {
if (isUniqueViolation(error)) {
return new Response(null, { status: 200 });
}
throw error;
}
await queue.enqueue("shopify-webhook", { webhookId, topic, shop, payload });
return new Response(null, { status: 200 });
};
function isUniqueViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error
&& (error as { code: string }).code === "P2002";
}
The unique constraint is doing the work here. Checking “have I seen this id?” and then inserting is two steps, and two retries arriving within milliseconds of each other can both pass the check. One insert with a unique index on webhookId turns the race into a database error you catch, and the error code (P2002 is Prisma’s) is the branch that returns 200 for the duplicate.
Returning 200 for a duplicate matters. A 4xx or 5xx tells Shopify the delivery failed, so it retries again, which is the loop you were trying to end.
If you are not using the Remix package, the verification guide’s manual check is short: compute an HMAC-SHA256 of the raw request body with your app’s client secret, base64 it, and compare to X-Shopify-Hmac-Sha256 with crypto.timingSafeEqual(). Compute it over the raw body: if your framework has already parsed and re-serialised the JSON, the digest will not match.
What should the worker do with the payload?
Whatever the webhook was for, but with the ordering problem in mind. Before writing a product or order from a payload, compare its updated_at to the copy you already hold and skip the write if yours is newer. That one comparison makes the worker safe to run on out-of-order deliveries and on the replayed deliveries you will eventually trigger on purpose when debugging.
Keep the delivery table small. You need webhook ids for as long as Shopify might retry the same delivery, which the troubleshooting page puts at four hours. Keeping them for a week is cautious and costs nothing. Keeping them forever is a table that grows with every order on every store you are installed on.
The worker also owns rate limiting. Webhook bursts (a bulk product edit fires one products/update per product) are exactly when a naive handler that hits the Admin API per delivery runs out of budget; our post on budgeting Admin API calls by query cost covers the cost model that applies here.
How do you catch the webhooks that never arrive?
With a scheduled job that asks Shopify what changed. The best-practices page recommends “background reconciliation jobs or user-triggered synchronization” that query the API with an updated_at filter. In practice that is a cron that runs every hour or so per shop, queries orders(query: "updated_at:>2026-10-07T09:00:00Z") or the equivalent for your resource, and runs the same upsert logic the webhook worker uses.
Sharing that upsert is what makes the job cheap to write. The webhook worker and the reconciliation job should call one function that takes a resource and a timestamp and decides whether to write, because two copies of that logic will drift, and the drift shows up as a product that is correct after reconciliation and wrong again after the next webhook.
For a store with a large catalogue, do the reconciliation with a bulk operation rather than paginated queries; the bulk operations post shows the export side of that.
Reconciliation also covers the failure mode that retries cannot: the deleted subscription. Register subscriptions in shopify.app.toml so the CLI recreates them on deploy, and have the reconciliation job log loudly when it finds changes the webhook path missed. You want to know the subscription is gone before the merchant does.
What I would actually do
Acknowledge in under a second, dedupe with a unique index on the webhook id, do the work in a queue worker that compares timestamps before writing, and run an hourly reconciliation that shares the worker’s upsert.
The case where I would not bother with the queue is an app with one store and a handler that does one cheap database write. There the five-second budget is not under threat and the unique index alone gets you most of the way. The moment you are installed on more than a handful of stores, or the handler calls the Admin API, put the queue in.
Whoooop builds Shopify apps and the integrations behind them, including the webhook and reconciliation plumbing that keeps an ERP, a warehouse or a CRM in step with a store. If your current integration drops orders or processes them twice, that is the kind of problem our Shopify development work is set up for.