# Shopify Webhooks: Topics, Subscriptions, Retries and Testing

> Set up Shopify webhooks in the admin, shopify.app.toml or GraphQL. Headers, topics, filters, the 5s timeout, 8 retries in 4 hours, duplicates and testing.

Source: https://webhooker.eu/blog/shopify-webhooks-guide
Last updated: 2026-09-26

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](https://webhooker.eu/blog/shopify-webhook-verification-gdpr), 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.

| Method | Who it’s for | Scope | Signed with |
| --- | --- | --- | --- |
| Shopify admin (Settings, Notifications, Webhooks) | A merchant wiring one store to a tool such as a spreadsheet, an ERP or Zapier | One store | A store-specific key shown in the admin |
| App-specific subscription in `shopify.app.toml` | App developers; Shopify’s default recommendation | Every store that installs the app | The app’s client secret |
| Shop-specific subscription through the GraphQL Admin API | Apps whose topics, URLs or filters differ per store | One store per subscription | The app’s client secret |

Shopify’s guidance on [choosing a subscription type](https://shopify.dev/docs/apps/build/webhooks/subscribe) 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](https://help.shopify.com/en/manual/fulfillment/setup/notifications/webhooks): 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:

```toml
[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:

```graphql
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](https://shopify.dev/docs/apps/build/webhooks). 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](https://shopify.dev/docs/apps/build/webhooks/delivery-structure):

```http
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": [...]}
```

| Header | What it carries | What you do with it |
| --- | --- | --- |
| `X-Shopify-Topic` | The topic, such as `orders/create` | Route to the right handler |
| `X-Shopify-Hmac-Sha256` | Base64 HMAC-SHA256 of the raw body | Verify before parsing |
| `X-Shopify-Shop-Domain` | The store’s `myshopify.com` domain | Find the tenant in a multi-store app |
| `X-Shopify-API-Version` | The API version used to serialize the payload | Catch version drift in logs |
| `X-Shopify-Webhook-Id` | A unique key per delivery | Deduplicate |
| `X-Shopify-Event-Id` | An id shared by all deliveries caused by one merchant action | Correlate `orders/create` and `orders/paid` from the same checkout |
| `X-Shopify-Triggered-At` | When Shopify triggered the delivery | Order events that arrive out of sequence |
| `X-Shopify-Name` | An optional name you gave the subscription | Tell 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](https://shopify.dev/docs/api/webhooks), and it is long. Most store integrations need a small subset of it:

| Topic | Fires when | Typical use |
| --- | --- | --- |
| `orders/create` | An order is placed | Push to an ERP, a warehouse or a spreadsheet |
| `orders/paid` | An order’s payment is captured | Start fulfilment only once money has arrived |
| `orders/updated` | Anything on an order changes | Keep a mirror in sync; noisy, so filter it |
| `orders/cancelled` | An order is cancelled | Stop fulfilment, release stock |
| `refunds/create` | A refund is issued | Accounting, restocking |
| `fulfillments/create` | A fulfilment is created | Tracking emails, 3PL sync |
| `products/update` | A product or its variants change | Sync catalogues and prices to other channels |
| `inventory_levels/update` | Stock at a location changes | Keep marketplace stock counts accurate |
| `customers/create` | A customer account is created | CRM and newsletter sync |
| `app/uninstalled` | A store uninstalls your app | Stop 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](https://webhooker.eu/blog/shopify-webhook-verification-gdpr).

`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](https://shopify.dev/docs/apps/build/webhooks/delivery-filtering), [payload modification](https://shopify.dev/docs/apps/build/webhooks/customize/modify-payloads)):

```toml
[[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:

- Every field the filter mentions must also be in `include_fields`, or it can’t be evaluated.
- An invalid field reference doesn’t raise an error on delivery. It suppresses all deliveries for that subscription. Test a new filter against a real change before you trust the silence.
- When `include_fields` trims a payload a lot, two updates can produce identical bodies, and Shopify may debounce them. Keep a field that always changes, such as `updated_at`, in the list.

## How fast does your endpoint have to answer?

Shopify has a [one-second connection timeout and a five-second timeout for the entire request](https://shopify.dev/docs/apps/build/webhooks/subscribe/https). 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](https://shopify.dev/docs/apps/build/webhooks/troubleshooting-webhooks), 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”](https://shopify.dev/docs/apps/build/webhooks/ignore-duplicates), and that it [“doesn’t guarantee ordering within a topic, or across different topics for the same resource”](https://shopify.dev/docs/apps/build/webhooks/best-practices).

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](https://webhooker.eu/blog/webhook-idempotency-keys), and the theory in [at-least-once vs exactly-once delivery](https://webhooker.eu/blog/at-least-once-vs-exactly-once-webhooks).

## 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:

```js
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](https://webhooker.eu/blog/webhook-signature-verification-failed). A worker then claims `pending` rows ([with `FOR UPDATE SKIP LOCKED` if several workers share the table](https://webhooker.eu/blog/postgres-job-queue-skip-locked)) 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”](https://shopify.dev/docs/apps/build/webhooks/subscribe/https) 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](https://shopify.dev/docs/api/shopify-cli/app/app-webhook-trigger) straight to your machine:

```bash
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](https://webhooker.eu/blog/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](https://webhooker.eu/tools/webhook-tester). To fire signed Shopify-style requests at a local handler without a store, [webhook-mock-sender](https://webhooker.eu/tools/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](https://webhooker.eu/blog/what-is-a-webhook-gateway) takes over that job. It answers Shopify at once and deals with your service on its own schedule.

With [Webhooker](https://webhooker.eu/#features) you create a source, choose the [Shopify verification preset](https://docs.webhooker.eu/receive/verification/) 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 `200`s 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](https://webhooker.eu/blog/webhook-dead-letter-queue-replay) until you [replay them](https://docs.webhooker.eu/deliver/retries-and-replay/), 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](https://webhooker.eu/blog/are-webhooks-personal-data-gdpr). Webhooker keeps ingest, storage and delivery [inside the EU](https://webhooker.eu/blog/eu-hosted-webhook-infrastructure), and the [free plan](https://webhooker.eu/pricing) covers 10,000 events a month. To try it on a development store, [create a free account](https://app.webhooker.eu/register), follow the [Shopify tutorial](https://docs.webhooker.eu/tutorials/shopify/), 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`.
