← All articles

Shopify Webhooks: Topics, Subscriptions, Retries and Testing

Webhooker Team Updated 15 min read
Flat illustration of Shopify webhook delivery: a shopping bag with a receipt sends envelopes down a pipe; one branch runs past a stopwatch into a server marked with a cross, one envelope loops back as a retry, and a blue node carrying the Webhooker pulse lines the rest up next to a server with a checkmark.

Shopify webhooks are HTTP POST requests that Shopify sends to your endpoint when something happens in a store: an order is created, a product changes, the app is uninstalled. Each delivery names its topic in X-Shopify-Topic, carries a unique X-Shopify-Webhook-Id, and is signed with a base64 HMAC in X-Shopify-Hmac-Sha256. Your endpoint gets one second to accept the connection and five seconds to answer with a 2xx. If it fails, Shopify retries up to eight times over four hours. If the failures keep going, Shopify removes the subscription.

That last part is what makes Shopify different from Stripe or GitHub. A long outage costs you more than a few events: the subscription itself disappears, and new orders stop arriving until someone notices. This guide covers how to subscribe, what a delivery contains, which topics are worth handling, and how to build a receiver that survives a bad afternoon. Every Shopify fact links to shopify.dev or the Shopify Help Center, checked on 26 September 2026. HMAC verification and the mandatory GDPR topics have their own article, so they get a summary here.

Admin, shopify.app.toml or GraphQL: how should you subscribe?

There are three ways to get webhooks out of Shopify, and they suit different people.

MethodWho it’s forScopeSigned with
Shopify admin (Settings, Notifications, Webhooks)A merchant wiring one store to a tool such as a spreadsheet, an ERP or ZapierOne storeA store-specific key shown in the admin
App-specific subscription in shopify.app.tomlApp developers; Shopify’s default recommendationEvery store that installs the appThe app’s client secret
Shop-specific subscription through the GraphQL Admin APIApps whose topics, URLs or filters differ per storeOne store per subscriptionThe app’s client secret

Shopify’s guidance on choosing a subscription type is short: “Choose app-specific subscriptions unless your topics, delivery URIs, or filters need to vary between shops.” Most apps should follow it.

In the Shopify admin

The Help Center steps: go to Settings, then Notifications, click Webhooks, then Create webhook. Pick the event, the format (JSON or XML), the URL and the webhook API version, and save. Choose JSON unless something downstream only reads XML.

Shopify signs these webhooks with a key “unique to your store”, shown on the same page. It is a different secret from any app’s client secret, so a receiver that verifies admin-created webhooks needs that key. Each row has a Send test button that posts a sample payload to the URL.

One detail on that Help Center page is easy to skim past: if the destination “repeatedly returns a non-200 status response, then the webhook subscription is automatically deleted from your Shopify admin.” For a merchant, a URL that broke over the weekend means the webhook is gone on Monday.

In shopify.app.toml

App-specific subscriptions live in the app’s configuration file and are deployed with the Shopify CLI:

[webhooks]
api_version = "2026-07"

[[webhooks.subscriptions]]
topics = ["orders/create", "orders/updated", "app/uninstalled"]
uri = "https://example.com/webhooks/shopify"

[[webhooks.subscriptions]]
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
uri = "https://example.com/webhooks/shopify/compliance"

api_version decides the shape of every payload, and the X-Shopify-API-Version header on each delivery tells you which version was used. Bump it on purpose, together with the code that parses the payloads. App-specific subscriptions support every topic except three product feed topics (product_feeds/full_sync, product_feeds/full_sync_finish and product_feeds/incremental_sync), which need a shop-specific subscription.

With the GraphQL Admin API

Shop-specific subscriptions are created per store with the webhookSubscriptionCreate mutation:

mutation webhookSubscriptionCreate(
  $topic: WebhookSubscriptionTopic!
  $webhookSubscription: WebhookSubscriptionInput!
) {
  webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
    webhookSubscription { id topic uri }
    userErrors { field message }
  }
}

The topic is passed as a GraphQL enum, so orders/create becomes ORDERS_CREATE. Use this route when one merchant wants order webhooks and another doesn’t, or when the delivery URL depends on the store.

Besides HTTPS, Shopify can deliver to Google Pub/Sub or Amazon EventBridge. Both take the timeout problem off your hands, and both mean you are running a cloud queue.

What does a Shopify webhook delivery look like?

Here is an orders/create delivery, shortened. The header names come from Shopify’s delivery structure reference:

POST /webhooks/shopify HTTP/1.1
Content-Type: application/json
X-Shopify-Topic: orders/create
X-Shopify-Hmac-Sha256: XWmrwMey6OsLMeiZKwP4FppHH3cmAiiJJAweH5Jo4bM=
X-Shopify-Shop-Domain: example-store.myshopify.com
X-Shopify-API-Version: 2026-07
X-Shopify-Webhook-Id: b54557e4-bdd9-4b37-8a5f-bf7d70bcd043
X-Shopify-Event-Id: 98880550-7158-44d4-b7cd-2c97c8a091b5
X-Shopify-Triggered-At: 2026-09-26T09:14:02.713Z

{"id": 820982911946154508, "email": "jon@example.com", "financial_status": "paid", "total_price": "403.00", "currency": "EUR", "updated_at": "2026-09-26T11:14:01+02:00", "line_items": [...]}
HeaderWhat it carriesWhat you do with it
X-Shopify-TopicThe topic, such as orders/createRoute to the right handler
X-Shopify-Hmac-Sha256Base64 HMAC-SHA256 of the raw bodyVerify before parsing
X-Shopify-Shop-DomainThe store’s myshopify.com domainFind the tenant in a multi-store app
X-Shopify-API-VersionThe API version used to serialize the payloadCatch version drift in logs
X-Shopify-Webhook-IdA unique key per deliveryDeduplicate
X-Shopify-Event-IdAn id shared by all deliveries caused by one merchant actionCorrelate orders/create and orders/paid from the same checkout
X-Shopify-Triggered-AtWhen Shopify triggered the deliveryOrder events that arrive out of sequence
X-Shopify-NameAn optional name you gave the subscriptionTell subscriptions apart in shared handlers

The two id headers get mixed up all the time. X-Shopify-Webhook-Id identifies one delivery, and that’s the one you deduplicate on. X-Shopify-Event-Id stays the same across different topics fired by one action, so using it as a dedupe key would throw away the orders/paid that followed an orders/create.

Which Shopify webhook topics should you handle?

Shopify publishes the full list in its webhooks reference, and it is long. Most store integrations need a small subset of it:

TopicFires whenTypical use
orders/createAn order is placedPush to an ERP, a warehouse or a spreadsheet
orders/paidAn order’s payment is capturedStart fulfilment only once money has arrived
orders/updatedAnything on an order changesKeep a mirror in sync; noisy, so filter it
orders/cancelledAn order is cancelledStop fulfilment, release stock
refunds/createA refund is issuedAccounting, restocking
fulfillments/createA fulfilment is createdTracking emails, 3PL sync
products/updateA product or its variants changeSync catalogues and prices to other channels
inventory_levels/updateStock at a location changesKeep marketplace stock counts accurate
customers/createA customer account is createdCRM and newsletter sync
app/uninstalledA store uninstalls your appStop jobs, revoke tokens, start the cleanup clock

Public apps must also handle the three compliance topics: customers/data_request, customers/redact and shop/redact. They must answer 401 when the HMAC is invalid. shop/redact arrives 48 hours after an uninstall, and you have 30 days to act on a redaction. The details are in Shopify HMAC and GDPR topics.

orders/create fires when the order exists, not when it is paid. A payment method that settles later still creates the order right away. If “order means ship it” is baked into your handler, move that logic to orders/paid.

How do you filter Shopify webhooks and trim the payload?

A busy store fires orders/updated and products/update constantly. Shopify offers two ways to cut that down, both configured on the subscription (delivery filtering, payload modification):

[[webhooks.subscriptions]]
topics = ["products/update"]
uri = "https://example.com/webhooks/shopify/products"
filter = "variants.price:>=10.00"
include_fields = ["id", "variants.id", "variants.price", "updated_at"]

filter uses Shopify’s search syntax: field:value, comparisons such as >=, wildcards, AND and OR, and a leading - to negate. Nested fields use dots. Filters need API version 2024-07 or later.

There are three rules to know before you rely on them:

How fast does your endpoint have to answer?

Shopify has a one-second connection timeout and a five-second timeout for the entire request. Anything outside the 2xx range counts as a failure, and that includes redirects: a 301 from http to https, or from a trailing slash to none, fails every delivery.

Five seconds is shorter than it sounds. A handler that calls the Admin API to fetch the full order, writes to a remote warehouse system and posts to a chat channel before it responds will miss it on a slow day. Shopify’s own advice is to “delay processing until after you’ve sent a response”. In practice that means you verify, store and answer, then do the work from a queue.

What happens when deliveries fail?

According to the troubleshooting guide, Shopify retries a failed delivery up to eight times within four hours. If failures persist after that, the subscription is removed. For app subscriptions the store owner gets an email when a webhook fails. For admin-created webhooks the subscription is deleted after repeated non-200 responses.

Picture a Friday evening deploy that breaks the webhook route. Four hours later, Shopify stops retrying the orders from that evening. Keep failing and the subscription goes too, so Saturday’s orders never get sent at all. Recovery is on you. Shopify’s guide says to re-subscribe to the topics and import the missing data through the API.

Shopify treats a failure rate above 0.5% as higher than average, so put an alert well below the point where the subscription is at risk.

Are Shopify webhooks delivered once and in order?

Neither is guaranteed. Shopify says your app “might receive the same webhook more than once, for example after a network timeout or a retry”, and that it “doesn’t guarantee ordering within a topic, or across different topics for the same resource”.

For duplicates, store X-Shopify-Webhook-Id under a unique constraint and skip ids you have seen, answering 200 so Shopify stops retrying. For ordering, compare updated_at in the payload, or X-Shopify-Triggered-At, with what you last stored, and ignore anything older. An orders/updated from 11:14:05 that arrives after one from 11:14:09 should not roll the order back.

Shopify also says plainly that “webhook delivery isn’t always guaranteed”, and recommends a periodic reconciliation job. That job queries the Admin API for objects with updated_at after its last run and fixes whatever the webhooks missed. It’s boring, and it’s the only thing that catches an event Shopify never sent. The storage side of deduplication is in idempotency keys for webhook consumers, and the theory in at-least-once vs exactly-once delivery.

A receiver that verifies, deduplicates and answers fast

Shopify signs app webhooks with the app’s client secret: an HMAC-SHA256 over the raw body, base64-encoded. The encoding is the usual trap, because most HMAC examples online produce hex. Here is a minimal Express receiver:

const crypto = require("crypto");
const express = require("express");
const { Pool } = require("pg");

const database = new Pool();
const app = express();

// Two comma-separated secrets only while a client secret rotation is in progress.
const clientSecrets = process.env.SHOPIFY_CLIENT_SECRETS.split(",");

function hmacMatches(rawBody, hmacHeader) {
  if (!hmacHeader) return false;
  const received = Buffer.from(hmacHeader, "base64");
  return clientSecrets.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
    return expected.length === received.length && crypto.timingSafeEqual(expected, received);
  });
}

app.post("/webhooks/shopify", express.raw({ type: "*/*" }), async (req, res) => {
  if (!hmacMatches(req.body, req.get("X-Shopify-Hmac-Sha256"))) {
    return res.status(401).send("invalid hmac");
  }

  // webhook_id is the primary key, so a duplicate delivery inserts nothing.
  await database.query(
    `INSERT INTO shopify_deliveries (webhook_id, topic, shop_domain, triggered_at, payload, status)
     VALUES ($1, $2, $3, $4, $5, 'pending')
     ON CONFLICT (webhook_id) DO NOTHING`,
    [
      req.get("X-Shopify-Webhook-Id"),
      req.get("X-Shopify-Topic"),
      req.get("X-Shopify-Shop-Domain"),
      req.get("X-Shopify-Triggered-At"),
      req.body.toString("utf8"),
    ],
  );

  res.sendStatus(200);
});

express.raw() keeps the body as bytes. Shopify’s docs warn that a body parser such as express.json() running first breaks verification, since it re-serializes the payload. That is the most common of the six causes of a failed signature check. A worker then claims pending rows (with FOR UPDATE SKIP LOCKED if several workers share the table) and dispatches on topic.

The two-secret list is there for rotation. When you rotate an app’s client secret, Shopify says it “can take up to an hour” before deliveries are signed with the new one. Accept both for that hour, then drop the old one. If you accept only the new secret from the moment you rotate, every delivery in that hour fails with 401 and starts counting toward removal.

How do you test Shopify webhooks locally?

Shopify won’t deliver to localhost, so something public has to receive the webhook and pass it on. You have four options.

shopify app webhook trigger

The Shopify CLI can send a sample payload straight to your machine:

shopify app webhook trigger \
  --topic orders/create \
  --api-version 2026-07 \
  --address http://localhost:3000/webhooks/shopify \
  --client-secret "$SHOPIFY_CLIENT_SECRET"

With --client-secret, the request carries a valid X-Shopify-Hmac-Sha256, so your verification code runs for real. The limits are in the docs: the payload is always the same sample, failed triggers are never retried, and it can’t confirm that your actual subscriptions work. It checks the handler, not the setup.

Send test in the admin

For admin-created webhooks, Send test on the webhook row posts a sample to the configured URL. It needs a public URL.

A tunnel

shopify app dev opens a tunnel for app development, and ngrok or Cloudflare Tunnel do the same for any server. Deliveries that arrive while the tunnel is down fail, and they count toward the eight retries. Our comparison of ngrok alternatives for webhooks goes through the options.

A gateway with a CLI

The gateway’s URL stays registered with Shopify and accepts deliveries whether your laptop is on or not. A CLI forwards them to localhost while you work. Webhooker works this way.

To see what Shopify actually sends, point a test webhook at our webhook tester. To fire signed Shopify-style requests at a local handler without a store, webhook-mock-sender computes the base64 HMAC and can send the same delivery twice to exercise your dedupe path.

Then test the failure on purpose. Make the handler return 500, place a test order on a development store, watch the retries come in, fix the handler and check that the row in shopify_deliveries appears once.

Putting a gateway in front of Shopify

We build Webhooker, so read this section as a vendor describing its own product.

Everything above comes down to one weak point. Your endpoint has to answer within five seconds every time, and a long enough outage costs you the subscription as well as the events. A webhook gateway takes over that job. It answers Shopify at once and deals with your service on its own schedule.

With Webhooker you create a source, choose the Shopify verification preset and enter the app’s client secret (or the store’s key for admin-created webhooks). Then use the source’s ingest URL (https://app.webhooker.eu/in/<token>) as the uri in shopify.app.toml or in the admin form. The preset checks the base64 X-Shopify-Hmac-Sha256. A request that doesn’t match gets 401, is kept for the audit trail and is never forwarded. A valid one is stored and acknowledged before delivery to your service starts, so Shopify sees fast 200s even while your app is down, and the subscription is never at risk.

Delivery to your service then follows Webhooker’s own schedule: up to six attempts over about five hours with exponential backoff, behind a per-destination circuit breaker. Events that run out of attempts wait in a dead-letter queue until you replay them, one by one or in bulk. Payloads and delivery history are kept for 14 days on the free plan and 30 or 90 days on paid plans. One source can fan out to several destinations, and filters on headers and body decide which events each one gets, for example routing X-Shopify-Topic: orders/paid to fulfilment and products/update to a catalogue sync.

There are two limits to check first. Webhooker accepts request bodies up to 1 MB, so a store with very large orders should check its payload sizes before switching. And deduplication stays in your code, because retries and replays are at-least-once by design.

Shopify order payloads carry names, email addresses, phone numbers and shipping addresses, which makes them personal data under GDPR. Webhooker keeps ingest, storage and delivery inside the EU, and the free plan covers 10,000 events a month. To try it on a development store, create a free account, follow the Shopify tutorial, stop your own endpoint and place a test order. The event will sit in Webhooker until your service is back.

Frequently asked questions

How do I create a webhook in Shopify?

In the Shopify admin, go to Settings, then Notifications, click Webhooks, then Create webhook. Choose the event, JSON or XML, the URL and the API version, then save. Apps declare subscriptions in shopify.app.toml under [[webhooks.subscriptions]] with topics and uri, or create them per store with the GraphQL webhookSubscriptionCreate mutation.

Does Shopify retry failed webhooks?

Yes. Shopify retries a failed delivery up to eight times over four hours. A delivery fails if your endpoint doesn’t accept the connection within one second, doesn’t answer within five seconds, or answers with anything outside 2xx, including redirects. If failures persist, Shopify removes the subscription, and admin-created webhooks are deleted after repeated non-200 responses.

What is the Shopify webhook timeout?

One second to connect and five seconds for the whole request. Return 200 as soon as the HMAC is verified and the payload is stored, then process it asynchronously.

How do I stop duplicate Shopify webhooks?

Store the X-Shopify-Webhook-Id header under a unique constraint and skip deliveries whose id you have already seen, still answering 200. Don’t deduplicate on X-Shopify-Event-Id: it is shared by every delivery triggered by the same merchant action, across different topics.

How do I test Shopify webhooks locally?

Run shopify app webhook trigger --topic orders/create --api-version 2026-07 --address http://localhost:3000/webhooks/shopify --client-secret <secret> to send a signed sample payload to your machine. For real store events, expose your server through a tunnel or a webhook gateway with a CLI, since Shopify can’t deliver to localhost.