Webhooks and Events

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the GraphQL Admin API version 2026-10 (the latest stable version, released on 2026-10-01), Shopify CLI 5.0.0, @shopify/shopify-app-react-router 4.0.0 (the public app template still depends on ^3.0.1) and @shopify/shopify-api 15.0.1, and checked on 2026-10-27. Webhooks and Events are available on every Shopify plan, because they belong to the app and not to the merchant’s plan. What a subscription may receive is bounded by the access scopes of the app, and topics that carry personal data additionally need the protected customer data approval that is described in the page on authentication and access scopes.

A Shopify app that only reads data when a merchant opens it is easy to build. Most useful apps also need to react when something happens in the shop: an order is placed, a price changes, a customer asks to be forgotten, the app is uninstalled. Polling the Admin API for every one of those would waste the app’s rate limit and would still be late. Shopify therefore offers two push mechanisms, classic webhooks and the newer Events, and this page explains both: how a subscription is declared, which gates decide whether a delivery is sent, how to prove that a delivery really comes from Shopify, how to answer it so that it is not retried, which three webhook topics every App Store app must handle, and how Events change the model. It sits after the page on authentication and access scopes (which supplies the client secret used to sign deliveries) and before the pages on the Admin GraphQL API and bulk operations, whose finish notification is itself a webhook.

The running example is the Pocket Coffee shop (Spanish and English storefronts, the single-origin beans product with 18 variants, the Coffee Club group and the "order placed" logger). Its companion app logs every new order with the order number and the total, reacts when the price of a bean variant changes, and answers the three compliance webhooks.

Two facts about the state of the documentation at the check date are worth knowing before reading older material. First, Events are described by Shopify as the successor to classic webhooks, but webhooks "remain fully supported" and both can live in the same shopify.app.toml. Second, the general API versioning page still lists Events among the surfaces that are only "available in the unstable version", while the Events guides and the developer changelog of 2026-10-01 say they are generally available with API version 2026-10; this page follows the Events documentation and the changelog.

This page was prepared without a Shopify account, so no real delivery was ever received from Shopify. What did run locally is listed in What was run and what was not: the shopify.app.toml build with the CLI, the GraphQL documents validated against the downloaded 2026-10 schema, the HMAC computation in three languages, a Node receiver, and three React Router route handlers of the official app template that were exercised with requests signed by a placeholder secret.

Webhooks and Events at a glance

Both mechanisms follow the same five steps: declare a subscription (a topic and a destination), narrow which changes qualify, let Shopify send a delivery when a qualifying change happens, verify the delivery, and act on it. They differ in how much control each step gives you.

Shopify webhook and Events delivery pipeline: sender-side gates for classic webhooks and for Events, then the delivery channel and the receiver steps
Classic webhooks Events (Next Gen Events)

Topic format

A string such as products/update or orders/create.

A capitalised resource name such as Product, plus actions (create, update, delete).

Where it is configured

shopify.app.toml (app-specific subscriptions) or the GraphQL Admin API (shop-specific subscriptions).

shopify.app.toml only.

Which changes qualify

Every change of the topic. Narrowed afterwards by filter.

triggers: field paths such as product.variants.price. Required whenever actions includes update.

Suppressing a delivery

filter, a search-syntax expression evaluated on the payload.

query_filter, a search-syntax expression evaluated on the result of the subscription’s GraphQL query.

Payload

A fixed, REST-shaped JSON document, optionally trimmed with include_fields.

Metadata (topic, action, handle, fields_changed, query_variables) plus the result of your own GraphQL query in data.

Topic coverage

Every Shopify resource (236 topics in the 2026-10 Admin schema).

A growing subset of Admin GraphQL objects (18 topics at general availability).

Request header prefix

X-Shopify-

Shopify-

Verification

HMAC-SHA256 of the raw body in X-Shopify-Hmac-Sha256, HTTPS only.

The same algorithm in Shopify-Hmac-Sha256, HTTPS only.

Delivery destinations

HTTPS, Google Cloud Pub/Sub, Amazon EventBridge.

HTTPS, Google Cloud Pub/Sub, Amazon EventBridge.

The number of topics in the table comes from the WebhookSubscriptionTopic enum of the Admin GraphQL schema for 2026-10, introspected on 2026-10-27; the count of Events topics comes from the general availability announcement.

Delivery destinations

Every subscription has a uri, and the form of the uri selects the delivery method.

Method uri value Notes

HTTPS

An absolute https:// URL, or a relative path that starts with a slash (relative to the app’s URL).

Shopify sends an HTTP POST and verifies the TLS certificate. HMAC verification applies to this method only.

Google Cloud Pub/Sub

pubsub://<project-id>:<topic-id>

Grant the Shopify service account delivery@shopify-pubsub-webhooks.iam.gserviceaccount.com the Pub/Sub Publisher role on the topic. Recommended by Shopify for production.

Amazon EventBridge

The partner event source ARN, arn:aws:events:<aws-region>::event-source/aws.partner/shopify.com/<app-id>/<event-source-name>

Associate the event source with an event bus in the AWS console first.

A relative path is the practical choice during development. shopify app dev runs a tunnel whose URL changes at every start, and a subscription whose uri is the relative path /webhooks/orders/create follows the tunnel automatically, while an absolute URL would have to be rewritten each time. The CLI’s own schema message lists one more accepted form, kafka://<topic-id>, that the public guides do not describe, so this page does not rely on it.

Cloud event buses carry the delivery metadata inside the message instead of in HTTP headers, and they do not need HMAC verification, but the messages contain extra fields beyond the sample payloads of the webhooks reference. When a destination is an event bus, the receiver is a normal Pub/Sub or EventBridge consumer rather than a web route.

Subscription types

Classic webhooks can be subscribed in two ways, and mixing them for the same topic produces duplicate notifications.

App-specific Shop-specific

Defined in

shopify.app.toml, applied to every shop that installs the app. Recommended by Shopify.

The GraphQL Admin API (webhookSubscriptionCreate), per shop.

Compliance topics

Supported.

Cannot be subscribed through the Admin API; they can be configured in the app configuration file.

Metafield namespaces

Not supported. An Events query can include metafields instead.

metafieldNamespaces can be set.

Topics

Every topic except product_feeds/full_sync, product_feeds/full_sync_finish and product_feeds/incremental_sync.

Every topic.

Failing subscription

Never deleted by Shopify.

Deleted by Shopify after repeated failures.

Where to see it

Dev Dashboard, Versions, Configuration, Subscriptions. Shown as "config-managed" in the logs.

Query webhookSubscriptions. Shown with a Subscription ID in the logs.

Scopes

Declared in the app configuration file.

Declared in the configuration file or the Dev Dashboard, never through the Admin API.

Choose app-specific subscriptions unless the topics, destinations or filters have to differ from shop to shop. A custom app created in the Shopify admin cannot use the CLI tooling, so it cannot use shopify.app.toml; it has to create its shop-specific subscriptions with the Admin API and its own access token. When an app moves from shop-specific to app-specific subscriptions, it first deletes its existing subscriptions to the same topics (list them with the webhookSubscriptions query, remove the code that created them, run shopify app deploy, check the Versions page of the Dev Dashboard) and only then adds the declarative ones.

Declaring webhook subscriptions in shopify.app.toml

The [webhooks] table holds the API version and the subscriptions. Each subscription is one entry.

Field Required Meaning

api_version (in [webhooks])

Yes

The GraphQL Admin API version used to serialise the payloads of every app-specific subscription, for example 2026-10. Each delivery reports the version it used in the X-Shopify-API-Version header (or in the message for Pub/Sub and EventBridge).

topics

Yes, unless compliance_topics is used

One or more topic names. Several topics in one subscription route all of them to the same uri.

uri

Yes

The destination: an HTTPS URL, a relative path, a Pub/Sub URI or an EventBridge ARN.

include_fields

No

Array of field paths. When set, the payload contains only those fields. Nested fields use a period, for example variants.price.

filter

No

A search-syntax expression. A delivery whose payload does not satisfy it is not sent. Available since API version 2024-07.

name

No

A label echoed in the X-Shopify-Name header, so that several subscriptions to the same topic can be told apart. Letters, digits, - and _, at most 50 characters, unique per app. Introduced on 2026-02-11.

compliance_topics

No

Any of customers/data_request, customers/redact and shop/redact. Required for every App Store app (see The mandatory compliance webhooks).

The Pocket Coffee app declares the following file. It subscribes to app/uninstalled (so the app can delete its sessions), logs new orders with a trimmed payload, watches price changes of the bean variants with a filter, and answers the three compliance topics.

client_id = "00000000000000000000000000000000"
name = "pocket-coffee-app"
application_url = "https://example.invalid"
embedded = true

[access_scopes]
scopes = "read_products,read_orders"

[auth]
redirect_urls = ["https://example.invalid/api/auth"]

[webhooks]
api_version = "2026-10"

  [[webhooks.subscriptions]]
  uri = "/webhooks/app/uninstalled"
  topics = ["app/uninstalled"]

  [[webhooks.subscriptions]]
  name = "orders-create"
  uri = "/webhooks/orders/create"
  topics = ["orders/create"]
  include_fields = ["id", "name", "total_price", "updated_at"]

  [[webhooks.subscriptions]]
  name = "beans-price"
  uri = "/webhooks/products/update"
  topics = ["products/update"]
  include_fields = ["id", "status", "variants.id", "variants.sku", "variants.price", "updated_at"]
  filter = "status:active AND variants.price:>=8.90"

  [[webhooks.subscriptions]]
  uri = "/webhooks/compliance"
  compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]

Three details of the file deserve a comment. read_orders is the scope that orders/create requires (every topic lists its required scope in the webhooks reference), and because it exposes personal data it is also subject to the protected customer data review. The client_id and the URLs are placeholders. The filter and the include_fields of the products/update subscription are consistent: every field the filter refers to (status, variants.price) also appears in include_fields, which Shopify requires when both are set.

How a change reaches Shopify depends on where the file is applied. While shopify app dev runs, a saved change updates the subscriptions of the development store automatically. For production, the configuration is part of an app version: run shopify app deploy, which creates and releases a version, or shopify app deploy --no-release to create the version and have Shopify validate it without releasing it. Changing api_version follows the same path, and Shopify recommends moving to the latest stable version every quarter. Before changing it, send sample payloads of the new version to the handler with shopify app webhook trigger --api-version=<new-version>; subscriptions keep using the old version until the new configuration is deployed.

What the offline build checks

shopify app build --no-input validates shopify.app.toml without logging in. The file above was built with Shopify CLI 5.0.0 on 2026-10-27 and printed pocket-coffee-app built!. Mutated copies of the same file show which mistakes the CLI catches offline and which are left to Shopify’s servers at deploy time.

Change to the file Result of the offline build

compliance_topics = ["shop/delete"]

Rejected with an "Invalid enum value" error that lists the three valid compliance topics.

A subscription without uri

Rejected: [webhooks.subscriptions.3.uri]: Required.

uri = ""

Rejected: the URI must be a relative path starting with a slash, an HTTPS URL, a pubsub:// URI, a kafka:// URI or an EventBridge ARN.

An Events handle that contains spaces

Rejected: a handle can contain only letters, digits, underscores and hyphens.

A misspelled topic (orders/creat)

Accepted. Topic names are validated by Shopify.

api_version = "2026-13"

Accepted.

name = "orders create!"

Accepted, although the documented character set is letters, digits, - and _.

actions = ["modify"] in an Events subscription

Accepted.

An Events subscription with update and no triggers

Accepted, although the guides say triggers is required for update.

query_filter without a query

Accepted, although the guides say a query must also be configured.

A filter that uses fields missing from include_fields

Accepted.

A subscription that sets both topics and compliance_topics

Accepted.

The practical rule is that a green offline build proves the TOML syntax and a handful of enumerations, not the semantics. Topic names, trigger paths, filter fields and query complexity are checked by Shopify when the version is deployed, and shopify app deploy --no-release is the way to run those checks without publishing the change. That command needs a logged-in CLI and an app, so it was not run for this page.

Trimming and filtering webhook deliveries

A bare subscription to products/update fires for every product change (a title edit, a tag, a variant price) and sends the full REST-shaped product. Two optional fields reduce both the volume and the size.

include_fields

include_fields lists the paths to keep. For the beans subscription, ["id", "status", "variants.id", "variants.sku", "variants.price", "updated_at"] turns a product of several kilobytes into a short document:

{
  "id": 9554194432293,
  "status": "active",
  "variants": [
    {
      "id": 123456789,
      "sku": "PC-ETH-WHL-250",
      "price": "9.90"
    }
  ],
  "updated_at": "2026-10-27T10:00:00+01:00"
}

The same option exists in the Admin API as includeFields. One side effect surprises people: when include_fields reduces the payload to a few fields, consecutive events can produce identical payloads, and Shopify debounces identical payloads that arrive within a short window, dropping the later one. A subscription to orders/updated with include_fields = ["id", "line_items.title"] would therefore swallow consecutive price changes. Always include a field that changes with every update, such as updated_at, when every update matters.

filter

filter uses the Shopify API search syntax, with AND, OR and parentheses, and is evaluated against the payload at delivery time using current values.

filter = "status:active AND (product_type:Beans OR product_type:Equipment) AND variants.price:>=8.90"

The filter looks like a search query but behaves differently in four ways, summarised by Shopify.

filter Search

Invalid field

No deliveries are sent.

All documents are returned.

: operator

Equality.

Fuzzy match when fields are tokenised.

Field specification

The field must be named; free terms are not supported.

Term searches are supported.

Case

Case-sensitive.

Case-insensitive.

Further rules from the guide:

  • A field that holds an array of objects matches when at least one element matches (line_items.properties.name:_gift delivers when any line item has such a property).

  • variants is a top-level array of the product payload. If any single variant satisfies the filter, the webhook fires for any update of that product, including unrelated ones such as a title edit.

  • Tags are serialised as one comma-separated string in topics such as products/update and orders/updated, so a tag filter is a keyword match on that string rather than a membership test.

  • A filter is validated when the subscription is created: wrong syntax prevents creation, while an invalid field or a type mismatch lets the subscription be created and then suppresses every delivery, which looks like a silent failure.

  • If include_fields is also set, every field named in the filter must appear in include_fields.

  • The metaobjects/create, metaobjects/update and metaobjects/delete topics require a filter on the definition’s type, type:<type>, without wildcards. For app-owned definitions the full type app--<app-id>--<namespace> must be used, not the $app: shorthand.

Shop-specific subscriptions with the Admin API

When the destination or the topics depend on the shop, an app creates subscriptions at runtime with the webhookSubscriptionCreate mutation. The topic is passed as the enum value in screaming case (ORDERS_CREATE).

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

The variables select the topic and the destination. This example (with placeholders) subscribes a shop to new orders and keeps only the fields the Pocket Coffee logger prints:

{
  "topic": "ORDERS_CREATE",
  "webhookSubscription": {
    "uri": "https://example.com/webhooks/orders/create",
    "format": "JSON",
    "name": "orders-create",
    "includeFields": ["id", "name", "total_price", "updated_at"]
  }
}

The mutation was validated against the Admin GraphQL schema for 2026-10 downloaded on 2026-10-27, together with a query that lists the existing subscriptions before a migration to app-specific ones:

query ExistingSubscriptions {
  webhookSubscriptions(first: 25) {
    nodes {
      id
      topic
      uri
      filter
      includeFields
      createdAt
    }
  }
}

Both documents validate against the schema. Reading the same schema gives several facts that are easy to get wrong from memory:

  • WebhookSubscriptionInput has the fields format, includeFields, filter, metafieldNamespaces, metafields, name and uri. format is JSON or XML.

  • The WebhookSubscriptionTopic enum has 236 values in 2026-10. None of them is a compliance topic: CUSTOMERS_REDACT, CUSTOMERS_DATA_REQUEST and SHOP_REDACT do not exist, which is the schema-level reason why compliance webhooks cannot be created through the API.

  • On the WebhookSubscription object, callbackUrl and endpoint are deprecated in favour of uri.

  • Since 2025-08-08 webhookSubscriptionCreate and webhookSubscriptionUpdate accept every kind of URI, so the endpoint-specific mutations pubSubWebhookSubscriptionCreate, pubSubWebhookSubscriptionUpdate, eventBridgeWebhookSubscriptionCreate and eventBridgeWebhookSubscriptionUpdate are deprecated. They are still present in the 2026-10 schema.

  • The version of a shop-specific subscription is the version in the request URL (POST /admin/api/2026-10/graphql.json), not a field of the subscription.

A shop-specific subscription that fails eight consecutive deliveries is deleted by Shopify, so the app has to check that its subscriptions still exist (for example in its afterAuth hook or in a nightly job) and recreate the missing ones. In the React Router template the same result is expressed with the webhooks option of shopifyApp and registerWebhooks, which wraps the mutation:

const shopify = shopifyApp({
  webhooks: {
    ORDERS_CREATE: {
      deliveryMethod: DeliveryMethod.PubSub,
      pubSubProject: "<GCP-PROJECT>",
      pubSubTopic: "<PUB_SUB_TOPIC>",
    },
  },
  hooks: {
    afterAuth: async ({ session }) => {
      shopify.registerWebhooks({ session });
    },
  },
});

The Admin API itself is covered in the page on the Admin GraphQL API. The older REST Admin API also had a webhooks resource; REST has been a legacy API since 2024-10-01 and new public apps have had to use GraphQL only since 2025-04-01, so new code should use the mutation above or the TOML file.

What a delivery looks like

For an HTTPS destination, Shopify sends an HTTP POST with a JSON body and metadata in headers. Header names are case insensitive (HTTP/2 lowercases them), so a receiver must never compare them case-sensitively.

Header Meaning

X-Shopify-Topic

The topic, for example orders/create.

X-Shopify-Hmac-Sha256

The base64 HMAC-SHA256 of the raw body, signed with the app’s client secret. HTTPS only.

X-Shopify-Shop-Domain

The myshopify.com domain of the shop that produced the event.

X-Shopify-API-Version

The API version that serialised the payload.

X-Shopify-Webhook-Id

A unique composite key per delivery. Use it to deduplicate.

X-Shopify-Triggered-At

When Shopify triggered the delivery.

X-Shopify-Event-Id

An id shared by all deliveries produced by the same merchant action. If two subscriptions match the same event, the two deliveries have different webhook ids and the same event id.

X-Shopify-Name

The name of the subscription, when one was set.

The payload is, by default, the full REST resource of the topic, with REST-style numeric ids and snake_case field names, whatever the API version is; the version changes which fields exist. The webhooks reference lists the payload of every topic. Payloads do change between versions, and the changelog shows the kind of change to watch for: on 2026-03-06 the checkout_id field was removed from the orders/create, orders/updated, orders/paid, orders/cancelled, orders/fulfilled, orders/partially_fulfilled and orders/link_requested payloads (use checkout_token), and checkouts/create and checkouts/update lost their id (use token); from API version 2026-10 order webhooks include selling_plan_id on line items.

Three behaviours of the delivery system shape how a receiver must be written.

  • Ordering is not guaranteed, neither within a topic nor across topics of the same resource: a products/update can arrive before the products/create of the same product. Use X-Shopify-Triggered-At or the updated_at of the payload to order events.

  • Delivery is not guaranteed. Handler failures, downtime and the retry limit can all lose an event, so an app should run reconciliation jobs that periodically fetch data changed since the last run. Many Admin API queries accept an updated_at filter for exactly this.

  • Duplicates happen. Shopify minimises them, but a network timeout or a retry can send the same delivery twice.

Verifying deliveries

Anyone who knows the URL of the endpoint can send it a request that looks like a webhook. The HMAC header is what separates Shopify from everyone else, and a handler must verify it before it reads anything from the body.

The algorithm

  1. Take the raw bytes of the request body, exactly as received.

  2. Compute HMAC-SHA256 over them, using the app’s client secret as the key.

  3. Base64-encode the 32-byte digest.

  4. Compare it, in constant time, with the value of the HMAC header.

  5. Reject the request with 401 Unauthorized when they differ or when the header is missing.

The key is the client secret of the app (the "API secret key"), not an access token and not a per-shop value. When the client secret is rotated, it can take up to an hour before deliveries are signed with the new secret, so a receiver that rotates secrets has to accept both for a while.

The computation is deterministic, so it can be cross-checked with any tool. With the placeholder secret placeholder-client-secret and the compliance payload of a shop/redact request as the file body.json (no trailing newline), three independent implementations print the same digest:

printf '{"shop_id":954889,"shop_domain":"pocket-coffee.myshopify.com"}' > body.json

openssl dgst -sha256 -hmac 'placeholder-client-secret' -binary body.json | base64
node -e 'console.log(require("crypto").createHmac("sha256","placeholder-client-secret").update(require("fs").readFileSync("body.json")).digest("base64"))'
python3 -c 'import hmac,hashlib,base64;print(base64.b64encode(hmac.new(b"placeholder-client-secret",open("body.json","rb").read(),hashlib.sha256).digest()).decode())'

Output of all three (run on 2026-10-27, OpenSSL being LibreSSL 3.3.6, Node 24.21.0):

Ly27oLW7vrIn11zMR7sXZfecjGr8L7Rm9rvF8d0fBx0=

A receiver in plain Node

The official page shows an Express handler. The version below uses only Node’s http and crypto modules so that it can run anywhere, and differs from the official snippet in one important detail: crypto.timingSafeEqual throws a RangeError when the two buffers have different lengths, so a request with a short or garbage header would crash the handler (or turn into a 500) if the lengths were not compared first.

import http from "node:http";
import crypto from "node:crypto";

const CLIENT_SECRET = process.env.SHOPIFY_API_SECRET ?? "placeholder-client-secret";

// Verify the base64 HMAC-SHA256 of the raw body, in constant time.
export function verifyHmac(rawBody, headerValue, secret = CLIENT_SECRET) {
  if (typeof headerValue !== "string" || headerValue.length === 0) return false;
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
  const received = Buffer.from(headerValue, "base64");
  // timingSafeEqual throws a RangeError when the lengths differ, so compare lengths first.
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const seen = new Set(); // use a persistent store (Redis, a database table) in production

const server = http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks); // keep the raw bytes: never re-serialise the JSON
    const hmac = req.headers["x-shopify-hmac-sha256"] ?? req.headers["shopify-hmac-sha256"];
    if (!verifyHmac(raw, hmac)) {
      res.writeHead(401).end("HMAC validation failed");
      return;
    }
    const id = req.headers["x-shopify-webhook-id"] ?? req.headers["shopify-webhook-id"];
    if (seen.has(id)) {
      res.writeHead(200).end("duplicate ignored");
      return;
    }
    seen.add(id);
    const topic = req.headers["x-shopify-topic"];
    const payload = JSON.parse(raw.toString("utf8"));
    console.log(`  handler: topic=${topic} shop=${req.headers["x-shopify-shop-domain"]} payload keys=${Object.keys(payload)}`);
    res.writeHead(200).end("ok");
  });
});

The receiver accepts both header spellings, which is how a single endpoint can serve classic webhooks and Events. It was run on 127.0.0.1 with a small driver that posts a pretty-printed order (it contains the non-ASCII character in "café") signed with the same placeholder secret. The seven scenarios and their results:

1 valid delivery        -> 200 ok
2 same delivery again   -> 200 duplicate ignored
3 tampered body         -> 401 HMAC validation failed
4 wrong secret          -> 401 HMAC validation failed
5 header of wrong size  -> 401 HMAC validation failed
6 missing header        -> 401 HMAC validation failed
7 re-serialised JSON    -> 401 HMAC validation failed
unguarded timingSafeEqual throws: ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH

Scenario 7 is the most common real-world bug: the body is parsed with JSON.parse and written back with JSON.stringify before it is verified. The compact output has different bytes from the pretty-printed original, so the signature no longer matches even though the data is the same. The last line shows what the length guard prevents.

Pitfalls that make valid deliveries fail

Pitfall What to do

A body parser runs first (express.json(), a framework that already decoded the body)

Capture the raw body before parsing and verify that. Put the verification middleware before any body parser. Do not verify a re-serialised document.

Wrong encoding

Hash the bytes. Do not decode them to a string and re-encode them, and compare the digests after decoding the base64 of the header, not as text.

Comparing with == or ===

Use a constant-time comparison, after checking that the lengths are equal.

Reading the header with an exact case

HTTP/2 lowercases header names. Look headers up case-insensitively.

Using an access token or the wrong app’s secret as the key

The key is the client secret of the app that owns the subscription. A shop with two environments (development and production apps) has two secrets.

Testing with the notification test button of the shop’s Notifications settings

Those requests fail verification, and the shopify-api library says so in its debug log. Use shopify app webhook trigger (which can sign with --client-secret) or trigger the real event in a development store.

Rotating the client secret

Allow up to an hour of deliveries signed with the new secret.

Big payloads in Events

For an Events delivery that was replaced by a download URL, verify the HMAC of the small body that was delivered, not of the downloaded content (see Overflow payloads).

Verification in the React Router template

The public app template does not verify anything by hand. authenticate.webhook(request) from @shopify/shopify-app-react-router reads the raw body, validates the HMAC and the required headers, loads the offline session of the shop when one exists, and returns a typed context. Its behaviour, read from the package source (the implementation is byte-identical in versions 3.0.2, which the template installs, and 4.0.0), is:

Situation Result

The request is not a POST

Throws a 405 Method not allowed response.

The HMAC does not match

Throws a 401 Unauthorized response.

Any other validation failure: a missing HMAC header, or a missing X-Shopify-Topic, X-Shopify-Shop-Domain, X-Shopify-API-Version or X-Shopify-Webhook-Id

Throws a 400 Bad Request response.

Valid

Returns apiVersion, shop, topic (in screaming case, ORDERS_CREATE), webhookId, the parsed payload, webhookType (webhooks or events), plus name, triggeredAt and eventId for classic webhooks or handle, action and triggeredAt for Events. When an offline session exists it also returns the session and an admin client.

The library tells classic webhooks and Events apart by the HMAC header it finds (Shopify-Hmac-Sha256 for Events, X-Shopify-Hmac-Sha256 otherwise), which is why one route can serve both. The session can be undefined after the app was uninstalled, because Shopify still delivers webhooks that were already queued. The template’s own webhooks.app.uninstalled route handles that explicitly: it deletes the shop’s sessions only when a session exists.

Because the functions throw Response objects, a route that calls authenticate.webhook needs no try block for the common failures: React Router turns the thrown response into the HTTP answer.

A handler for orders that runs

The handler below belongs to the Pocket Coffee app. It authenticates the request, claims the webhook id so that a duplicate is acknowledged but not processed, and logs the order. The claim is a database insert that relies on the primary key, which makes it correct under concurrent deliveries (a check followed by an insert would not be).

// Idempotency keys: one row per webhook or event delivery that was already accepted.
model WebhookReceipt {
  id         String   @id // X-Shopify-Webhook-Id (Shopify-Webhook-Id for Events)
  topic      String
  shop       String
  receivedAt DateTime @default(now())
}
// app/webhook-receipts.server.ts
import { Prisma } from "@prisma/client";
import db from "./db.server";

/**
 * Records a delivery id. Returns true the first time an id is seen and false for a
 * duplicate, so the caller can answer 200 and skip the work.
 */
export async function claimDelivery(
  webhookId: string,
  topic: string,
  shop: string,
): Promise<boolean> {
  try {
    await db.webhookReceipt.create({ data: { id: webhookId, topic, shop } });
    return true;
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError &&
      error.code === "P2002"
    ) {
      return false; // unique constraint: already processed
    }
    throw error;
  }
}
// app/routes/webhooks.orders.create.tsx
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { claimDelivery } from "../webhook-receipts.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const { topic, shop, payload, webhookId } = await authenticate.webhook(request);

  // Shopify can deliver the same webhook twice: process each id once.
  if (!(await claimDelivery(webhookId, topic, shop))) {
    return new Response(null, { status: 200 });
  }

  // The subscription uses include_fields = ["id", "name", "total_price", "updated_at"].
  console.log(`order placed ${payload.name} total ${payload.total_price} (${shop})`);
  return new Response(null, { status: 200 });
};

The route lives next to the template’s own webhooks.app.uninstalled.tsx and webhooks.app.scopes_update.tsx; its URL /webhooks/orders/create matches the uri of the subscription. The app/scopes_update handler of the template shows another useful payload: its current array lists the scopes the app now has.

Answering a delivery: speed, retries and monitoring

Respond first, work later

Shopify treats any answer outside the 2xx range, including a 3xx redirect, as an error. It waits one second to connect and five seconds for the whole request, and it reuses connections, so the endpoint should keep HTTP Keep-Alive enabled. The rule that follows is simple: verify, deduplicate, store the payload (or put it on a queue) and answer 200 at once; do the slow work in a worker. A compliance request that needs a database sweep, a price sync that calls another system and an order export all belong in the worker.

Retries and removal

If Shopify gets no answer or an error, it retries eight times over the next four hours, with growing intervals. After the eight attempts it stops. A shop-specific subscription is then deleted and warning emails go to the app’s emergency developer address; an app-specific subscription is not deleted, but the deliveries that failed are lost unless the app reconciles. For a long outage:

  • Shop-specific subscriptions have to be created again (check which ones exist and create only the missing ones). App-specific subscriptions do not need to be recreated.

  • Import the data that changed during the outage by querying the Admin API for objects updated since the outage began and feeding them to the same processing code the handlers use.

Monitoring in the Dev Dashboard

For apps created in the Dev Dashboard or with the CLI, the app’s Overview page charts successful deliveries, the failure rate and the 90th percentile response time over a chosen range, and the Logs page lists individual deliveries of the last seven days, filterable by topic, status and shop. Each delivery shows the response code, the topic, the URI, the subscription method ("App specific" or the shop-specific subscription id), the API version, the webhook id, the response time, the attempt number and the HMAC and headers that were sent. The logs are not real time and can be delayed by several minutes. Shopify’s rules of thumb for what to fix first:

Signal Meaning

Failed delivery rate above 0.5%

Higher than average. Look for a store-specific or payload-specific error, especially if one topic stands out.

Removed subscriptions

The app is no longer receiving the topic. Fix the cause and recreate the subscription.

Response times between four and five seconds

The handler is about to time out. Answer first and process later.

The same failure rate on every topic

The backend is probably not responding; check the application monitoring.

The mandatory compliance webhooks

Every app distributed through the Shopify App Store must subscribe to three compliance topics and handle them, whether or not the app stores personal data. They exist so that merchants can answer data subject requests under privacy laws such as the GDPR and the CPRA; Shopify applies one standard to every app, regardless of where the person lives. If the subscriptions or the handlers are missing, the app is rejected in review, and the problem has to be fixed before the next submission.

Topic When Shopify sends it What the app must do

customers/data_request

A customer asks the merchant for the data stored about them. Only apps with access to customer or order data receive it.

Give the merchant the data the app holds for the customer ids listed in the payload, directly (not in the HTTP answer).

customers/redact

A merchant asks to delete a customer’s data. Sent 10 days after the request when the customer placed no order in the past six months; otherwise withheld until six months have passed.

Delete or irreversibly anonymise the data of the customer and of the listed orders, unless the law requires retention.

shop/redact

48 hours after the merchant uninstalls the app.

Erase everything the app holds for the shop, including sessions and tokens.

The three payloads, with the shop and customer replaced by Pocket Coffee test values:

{
  "shop_id": 954889,
  "shop_domain": "pocket-coffee.myshopify.com",
  "orders_requested": [299938, 280263, 220458],
  "customer": { "id": 191167, "email": "ada@example.test", "phone": null },
  "data_request": { "id": 9999 }
}
{
  "shop_id": 954889,
  "shop_domain": "pocket-coffee.myshopify.com",
  "customer": { "id": 191167, "email": "ada@example.test", "phone": null },
  "orders_to_redact": [299938, 280263, 220458]
}
{
  "shop_id": 954889,
  "shop_domain": "pocket-coffee.myshopify.com"
}

The first is the customers/data_request payload, the second customers/redact and the third shop/redact. In some customer records only the email address is present.

Requirements

The endpoint must satisfy four rules.

  • Accept POST requests with a JSON body and Content-Type: application/json.

  • Verify the HMAC exactly as for any other webhook and answer 401 Unauthorized when it is invalid. Shopify’s requirements state that a request with an invalid signature must be refused.

  • Answer with a 2xx status to confirm receipt.

  • Complete the action within 30 days of receiving the request. The one exception is a redaction that the app cannot carry out because the law requires it to keep the data; in that case it must not delete it.

Compliance topics are subscribed in the app configuration: the TOML file, or the version form of the Dev Dashboard. The Admin API cannot create them (the enum has no such values, as shown earlier). They can be sent to one endpoint with a single subscription, as the Pocket Coffee file does, or to three endpoints. The template ships them commented out under the heading "Mandatory compliance topic for public apps only"; uncomment them and replace the uri.

A compliance handler that runs

The Pocket Coffee app keeps a small Coffee Club note per customer, so it has something to return and to erase. The handler authenticates the delivery, stores the request with its due date (30 days after receipt) and answers at once. A nightly job (not shown) reads the unfinished rows, produces the export or performs the deletion, and sets doneAt. The webhook id is the primary key, so a redelivered request collapses into the same row.

// Compliance requests are stored first and fulfilled later, within 30 days.
model ComplianceRequest {
  id         String    @id // webhook id: duplicates collapse into one row
  topic      String
  shop       String
  payload    String
  receivedAt DateTime  @default(now())
  dueAt      DateTime
  doneAt     DateTime?
}
// app/routes/webhooks.compliance.tsx
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";

const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;

export const action = async ({ request }: ActionFunctionArgs) => {
  // Throws a 401 Response when the HMAC is invalid and a 400 Response when
  // required headers are missing, so nothing below runs for a forged request.
  const { topic, shop, payload, webhookId } = await authenticate.webhook(request);

  switch (topic) {
    case "CUSTOMERS_DATA_REQUEST":
    case "CUSTOMERS_REDACT":
    case "SHOP_REDACT":
      // Acknowledge quickly: store the request now, fulfil it from a background job.
      await db.complianceRequest.upsert({
        where: { id: webhookId },
        update: {},
        create: {
          id: webhookId,
          topic,
          shop,
          payload: JSON.stringify(payload),
          dueAt: new Date(Date.now() + THIRTY_DAYS_MS),
        },
      });
      return new Response(null, { status: 200 });
    default:
      return new Response("Unhandled compliance topic", { status: 400 });
  }
};

The route handlers were type-checked inside a copy of the official React Router template (commit of 2026-10-09, react-router typegen followed by tsc --noEmit, no errors) and executed with signed Request objects, using the placeholder secret placeholder-client-secret and a local SQLite database. The results:

compliance customers/redact (valid)          -> 200
compliance customers/redact (same id again)  -> 200
compliance customers/redact (bad HMAC)       -> 401 Unauthorized
compliance shop/redact (valid)               -> 200
compliance, GET request                      -> 405 Method not allowed
compliance rows: [
  'wh-c1 CUSTOMERS_REDACT due 2026-11-26',
  'wh-c3 SHOP_REDACT due 2026-11-26'
]
order placed #1001 total 38.80 (pocket-coffee.myshopify.com)
orders/create (valid)                        -> 200
orders/create (duplicate id)                 -> 200
orders/create (missing topic header)         -> 400 Bad Request

The duplicate produced one stored row, not two, and the order was logged once. The forged request was rejected before the handler ran. What was not run is the part that depends on Shopify: a real compliance delivery, which cannot be triggered from a development store for customers/redact and shop/redact (they are sent after a delay), and the review that checks the endpoint.

Testing compliance endpoints

shopify app webhook trigger sends a sample payload of any topic to an address of your choice, and it can sign it when given the client secret, which makes it the right tool for these topics. It tests the handler, not the subscription: Shopify states that triggering a webhook manually does not test that the subscription is configured correctly. See Testing deliveries.

Next Gen Events

Events are Shopify’s second subscription mechanism. A webhook tells the app that "a product changed" and leaves the app to fetch what it needs and work out what changed. An Events subscription states which change the app cares about, what data it wants to receive with it, and which results it wants to be delivered, and Shopify does the filtering and the query before it sends anything. Events reached developer preview on 2026-05-27 and were declared generally available with API version 2026-10 on 2026-10-01.

The three gates

An Events subscription has three configurable gates, evaluated in this order:

  1. topic and actions decide which resource and which lifecycle transitions are relevant.

  2. triggers (required when actions includes update) list the field paths whose change qualifies an update. Multiple triggers are an implicit OR.

  3. query runs a GraphQL Admin API operation after the change, and its result becomes data; query_filter then evaluates current values in that result and suppresses the delivery when the expression is false.

Triggers detect that something changed. The query selects what the app needs to know, independently of what changed (a change of one field can trigger a query that reads other fields). The query filter checks the state. A query filter cannot detect a transition by itself, because it sees current values and not before and after.

Subscription fields

Field Required Meaning

api_version (in [events])

Yes

The GraphQL Admin API version used to validate and run the subscription query, for example 2026-10.

handle

Yes

A unique id for the subscription. Letters, digits, _ and -, at most 50 characters. Included in the payload and the headers, so it routes deliveries when several subscriptions share a topic.

topic

Yes

The capitalised resource name, such as Product. Child objects have no topic of their own: variants, for example, are surfaced under Product.

actions

Yes

Any of create, update, delete. create and delete apply to the topic entity itself and have no field-path narrowing; update covers the entity and the children routed to it.

uri

Yes

HTTPS URL, relative path, Pub/Sub URI or EventBridge ARN.

triggers

For update

Field paths. A parent trigger uses an explicit wildcard, such as product.variants.*; product.* matches every supported field of the product. Metafield triggers use the form product.metafield(namespace: 'custom', key: 'material').value.

query

No

A GraphQL Admin API operation. Not limited to the topic’s resource; it can have several root fields.

query_filter

No (needs query)

A search-syntax expression over the query result. Paths start at the same root as the query; there must be no space after the colon.

Events require Shopify CLI 3.92 or later; the CLI used here is 5.0.0. They can be configured only in shopify.app.toml, never through the Admin API, and unsupported topics fail validation when the app is deployed.

Topics at general availability

The 2026-10 announcement lists eighteen topics: Product and Collection; Customer and Company; Order, FulfillmentOrder, Refund and Return; InventoryItem, InventoryShipment, InventoryTransfer and Location; Article, Blog and Page; and MetafieldDefinition, Metaobject and MetaobjectDefinition. Each topic has its own supported triggers, query variables and access scope (the Product topic needs read_products, Order needs one of read_orders, read_marketplace_orders, read_buyer_membership_orders or read_quick_sale, Metaobject needs read_metaobjects). The Events reference is the source of truth, and the trigger paths it lists, not the GraphQL schema, are what validation checks. Derived fields (such as product.compareAtPriceRange) and auto-updated timestamps (such as product.updatedAt) are not valid triggers because they have no independent change signal. Deprecated fields can still be valid triggers. Every trigger of a Metaobject subscription must name the metaobject type with metaobject(type: '...').

Metafield triggers arrived on 2026-07-21 for Product, Order, Customer, Collection and Location (variant metafields through the Product topic), with support for both $app-owned and ordinary metafields, subject to the app’s access to the metafield. The page on metafields and metaobjects describes the data these triggers watch.

A price-change subscription for Pocket Coffee

The Coffee Club discount depends on prices, so the app wants to know when the price of a bean variant changes, but only for active products and only for variants at or above the 250 g Ethiopia price (8.90 plus 1.00 is 9.90 EUR).

[events]
api_version = "2026-10"

  [[events.subscription]]
  handle = "beans-price-change"
  topic = "Product"
  actions = ["update"]
  triggers = ["product.variants.price", "product.variants.compareAtPrice"]
  uri = "/events/products"
  query = """
  query price_change($productId: ID!, $variantsId: ID!) {
    product(id: $productId) {
      id
      title
      status
    }
    productVariant(id: $variantsId) {
      id
      sku
      price
      compareAtPrice
    }
  }
  """
  query_filter = "product.status:'ACTIVE' AND productVariant.price:>=8.90"

This file (placed in the same shopify.app.toml as the webhook subscriptions above) built successfully with Shopify CLI 5.0.0. Its query was validated against the Admin 2026-10 schema as a standalone operation, and a copy with the typo prise was rejected with Cannot query field "prise" on type "ProductVariant". Did you mean "price"? (the copy no longer uses $productId, so the validator also reported that variable as unused). The query variables $productId and $variantsId are filled by Shopify: ids flow up from the changed entity through its parents, so a variant change supplies both, while a change of a product field such as the title supplies only $productId.

A subscription must therefore use only variables that every one of its triggers supplies. A subscription with the triggers product.variants.price and product.title and a query that uses $variantsId is invalid, and Shopify answers variantsId is not available in product.title. Split changes that need different variables into separate subscriptions.

What a delivery contains

For an Events delivery, the HTTP body is a JSON document with these fields.

Field Meaning

topic, action, handle

The topic (Product), the lifecycle operation (create, update, delete) and the subscription handle.

fields_changed

An object with the three arrays added, updated and removed, each listing paths that embed the global ids of the entities that changed, for example product[id: 'gid://shopify/Product/123'].variants[id: 'gid://shopify/ProductVariant/456'].price. Adding a variant to a product keeps the product’s action at update and puts the variant path in added.

query_variables

The resolved ids as flat variables (productId, variantsId), the same ones the query receives.

data

Only with a query: the result of the query, evaluated after the change, so it shows the state at query time and not necessarily at the moment of the change.

errors

Only with a query: the GraphQL errors, when the query failed (a removed field, a deleted resource). A handler must check it.

A complete delivery of the subscription above, with Pocket Coffee values:

{
  "topic": "Product",
  "action": "update",
  "handle": "beans-price-change",
  "data": {
    "product": {
      "id": "gid://shopify/Product/1",
      "title": "Single-origin beans",
      "status": "ACTIVE"
    },
    "productVariant": {
      "id": "gid://shopify/ProductVariant/2",
      "sku": "PC-ETH-WHL-250",
      "price": "9.90",
      "compareAtPrice": null
    }
  },
  "fields_changed": {
    "added": [],
    "updated": [
      "product[id: 'gid://shopify/Product/1'].variants[id: 'gid://shopify/ProductVariant/2'].price"
    ],
    "removed": []
  },
  "query_variables": {
    "productId": "gid://shopify/Product/1",
    "variantsId": "gid://shopify/ProductVariant/2"
  }
}

The headers use the Shopify- prefix: Shopify-Topic, Shopify-Action, Shopify-Handle, Shopify-Api-Version, Shopify-Webhook-Id, Shopify-Triggered-At, Shopify-Shop-Domain and Shopify-Hmac-Sha256, and the User-Agent is Shopify-Captain-Hook. Two headers that earlier previews sent, shopify-event-id and shopify-resource-id, were removed on 2026-09-16, so a receiver must not depend on them. Verification and deduplication follow exactly the webhook rules, with Shopify-Hmac-Sha256 and Shopify-Webhook-Id; two subscriptions that match the same change produce two deliveries with different webhook ids.

Overflow payloads

Each delivery channel has a size limit: 5 MB for HTTPS, 10 MB for Google Cloud Pub/Sub and 256 KB for Amazon EventBridge. When the full content would exceed it, Shopify stores the content and sends a small body that contains only topic, action, handle, payload_url, payload_size_bytes and expires_at. fields_changed, query_variables and data are then found only in the download. Handlers have to be prepared for both shapes: check for payload_url first, verify the HMAC on the small body (not on the download), fetch the content before it expires and treat the URL as a short-lived credential. Keeping the query as small as the app needs also reduces the chance of overflow.

A handler for Events that runs

The route below is the Events counterpart of the orders handler. It authenticates the request with the same authenticate.webhook, deduplicates, follows payload_url when present, checks errors and then reads the variant.

// app/routes/events.products.tsx
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { claimDelivery } from "../webhook-receipts.server";

type ProductPriceEvent = {
  topic: string;
  action: "create" | "update" | "delete";
  handle: string;
  data?: {
    product: { id: string; title: string; status: string };
    productVariant: { id: string; sku: string | null; price: string; compareAtPrice: string | null };
  };
  errors?: unknown[];
  fields_changed?: { added: string[]; updated: string[]; removed: string[] };
  query_variables?: Record<string, string>;
  // Present instead of the fields above when the payload exceeded the channel limit.
  payload_url?: string;
  payload_size_bytes?: number;
  expires_at?: string;
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const { topic, shop, payload, webhookId } = await authenticate.webhook(request);
  if (!(await claimDelivery(webhookId, topic, shop))) {
    return new Response(null, { status: 200 });
  }

  let event = payload as ProductPriceEvent;
  if (event.payload_url) {
    // Overflow payload: the HMAC covered this small body, the full content is a download.
    const full = await fetch(event.payload_url);
    event = { ...event, ...((await full.json()) as ProductPriceEvent) };
  }
  if (event.errors?.length) {
    console.error("event query failed", event.handle, event.errors);
    return new Response(null, { status: 200 });
  }

  const variant = event.data?.productVariant;
  console.log(`price change ${variant?.sku}: ${variant?.price} (handle ${event.handle})`);
  return new Response(null, { status: 200 });
};

It was run with a delivery signed with the Shopify- headers, and it printed price change PC-ETH-WHL-250: 9.90 (handle beans-price-change) and answered 200; a second delivery with the same Shopify-Webhook-Id answered 200 and printed nothing. The overflow branch was type-checked but not exercised, because it needs a download URL.

Query limits

The query of an Events subscription has a complexity limit of 100 points. The limit was 250 points until the change announced on 2026-09-22. Complexity follows the GraphQL cost rules: scalars and enums cost zero, objects one point, and connections depend on first and last. The queries that Events run do not count against the app’s API rate limits. Shopify checks the complexity when an app version is created, without executing the query or waiting for an event, so shopify app deploy --no-release is the way to find out. If a query is over budget, the guidance is to split the subscription by the work it does, to query the changed child directly (the variant, using $variantsId, rather than the first 250 variants of the product), and to match each query to the variables its triggers provide. Several subscriptions for the same topic, each with its own triggers and query, are allowed.

Naming collisions to be aware of

Three different things are called events in Shopify documentation. The Events surface described here is the next generation of webhooks. The Admin API also has an Event interface (and a REST events resource) that records what happened to a resource, and on 2026-01-27 access to that data was limited to one year; this has nothing to do with Next Gen Events. Finally, the standard storefront events and the Web Pixels API are client-side analytics events. Only the first can be subscribed to from shopify.app.toml.

Choosing between webhooks and Events

Situation Prefer Why

The resource has an Events topic and the app needs one field or a few related fields

Events

Triggers and query filters move the filtering into the subscription, and the query returns the related data with the change.

The app needs to know which child was added or removed (a product joined a collection, a variant was removed)

Events

fields_changed.added and removed carry the path of the child, where a webhook needs a diff against stored state.

The topic is not in the list of Events topics (checkouts, carts, themes, app lifecycle, compliance, bulk operations)

Webhooks

Webhooks cover every Shopify resource; compliance topics exist only as webhooks.

The subscription must differ per shop or be created at runtime

Webhooks (shop-specific)

Events can only be declared in shopify.app.toml.

The handler already works and the volume is low

Webhooks, migrate one topic at a time

Both can live in one shopify.app.toml, and Shopify advises migrating subscription by subscription as topic coverage grows.

The migration guide gives a repeatable procedure. First translate the fields the handler reads: REST names mostly become camelCase GraphQL names, ids become global ids, and some fields change shape (for orders/create, financial_status becomes displayFinancialStatus, price fields become money sets such as totalPriceSet { shopMoney { amount currencyCode } }, and line items become connections with nodes). Then decide what triggers the subscription, because a query selects data but never decides when a delivery fires. For create, query the new resource by id; for update, choose the triggers; for delete, there is nothing to query, and the ids in query_variables identify what was deleted. Finally, optimise the query to stay within 100 points, and validate it with shopify app deploy --no-release.

The general availability announcement uses collection membership as its example, and it is a good illustration of what a webhook cannot do without extra calls:

[events]
api_version = "2026-10"

[[events.subscription]]
handle = "collection-membership"
topic = "Collection"
actions = ["update"]
triggers = ["collection.products"]
uri = "/api/events/collections"

query = """
  query CollectionMembership($collectionId: ID!, $productsId: ID!) {
    collection(id: $collectionId) {
      id
      hasProduct(id: $productsId)
    }
  }
"""

When product 456 joins collection 123, the delivery has action equal to update, fields_changed.added with the path of the product in the collection, query_variables with both ids and data.collection.hasProduct equal to true, so the search index of the app can be updated without fetching the collection’s product list. This query validates against the 2026-10 schema.

Testing deliveries

There are four ways to exercise a handler, and they test different things.

Method What it tests What it does not test

Trigger the real event in a development store (create a product, place a test order)

The whole chain: subscription, delivery, signature, handler. Most topics fire at once.

customers/redact and shop/redact do not fire immediately.

shopify app webhook trigger

The handler: Shopify sends a synthetic payload of the chosen topic and API version to your address.

The subscription itself. Shopify says manually triggered webhooks do not test subscriptions.

A signed request you build yourself, as in the Node and React Router examples above

The verification and the business logic, offline and in automated tests.

Anything about Shopify’s delivery behaviour.

The Dev Dashboard logs

What Shopify actually sent and what your endpoint answered, for the last seven days.

Nothing before the delivery.

The command is:

shopify app webhook trigger \
  --topic orders/create \
  --api-version 2026-10 \
  --delivery-method http \
  --address http://localhost:3000/webhooks/orders/create \
  --client-secret "${SHOPIFY_API_SECRET}"

Its flags, read from the CLI 5.0.0 help text, are --topic, --api-version, --delivery-method (http, google-pub-sub or event-bridge, inferred from the address when omitted), --address, --client-secret (used to return the HMAC header so the receiver can validate the origin), --client-id or -c for the app configuration, --path, --json, --no-input and --reset. In a non-interactive run, --topic, --api-version and --address are required. For local testing use http://localhost:<port>/<path>;; for remote HTTP testing an https:// URL; for Pub/Sub pubsub://<project-id>:<topic-id>; for EventBridge an ARN that starts with arn:aws:events:. The command needs a logged-in CLI and an app, so it was not run for this page; only its help text was read. The environment variable holding the secret is a placeholder reference and no value was used.

Troubleshooting

Symptom Likely cause and fix

Every delivery is rejected with 401

The HMAC is computed on a parsed or re-serialised body, with the wrong secret, or the header is read with the wrong case. Verify the raw bytes with the client secret of the app that owns the subscription.

Deliveries stopped and never came back

A shop-specific subscription was deleted after eight failed deliveries. Recreate it and reconcile the missed data.

Only some product updates arrive, or none

An invalid field in filter suppresses all deliveries; so does a type mismatch. Check the field names against the payload, and remember the filter is case-sensitive.

A product update fires although the filter should block it

variants is a top-level array: one matching variant is enough to deliver any update of the product.

Consecutive identical updates are missing

Debouncing of identical trimmed payloads. Add updated_at to include_fields.

The same order is processed twice

The handler is not idempotent. Claim X-Shopify-Webhook-Id (or Shopify-Webhook-Id) with a unique constraint.

The handler times out although the work is small

The answer must arrive within five seconds; move the work to a queue and answer first.

The subscription exists in the Dev Dashboard but nothing arrives in development

The tunnel URL changed. Use a relative uri so the subscription follows shopify app dev.

variantsId is not available in product.title

A query variable is not supplied by every trigger. Split the subscription.

An Events delivery has errors and no data

The query referenced a removed field or a deleted resource, or the app lacks the scope for a field. Log errors and reconcile.

An Events delivery contains only payload_url

The payload exceeded the channel limit. Download the content before expires_at, and verify the HMAC of the small body.

A topic needs a scope the app does not have

Add the scope to [access_scopes], deploy, and have the merchant approve it. Topics with personal data also need the protected customer data approval.

The app was rejected in review for compliance webhooks

The three topics are not subscribed, the endpoint does not return 401 for a bad signature, or it does not accept a JSON POST.

Pocket Coffee: putting it together

Pocket Coffee need Mechanism Notes

Log "order placed" with the number and total

Webhook orders/create, app-specific, include_fields

Handler claims the webhook id, logs #1001 38.80, answers 200. Needs read_orders.

React when a bean variant price changes to the Coffee Club threshold

Event on Product with triggers and query_filter

One delivery per qualifying variant change, with SKU and price in data.

Keep a search index of the Beans collection

Event on Collection with collection.products

fields_changed.added and removed carry the product ids.

Delete the app’s sessions when the merchant uninstalls

Webhook app/uninstalled

The session can already be gone when a duplicate arrives; check for it.

Answer a request from ada@example.test to erase her data

Compliance topic customers/redact

Stored on receipt, executed within 30 days.

Erase a shop’s data after uninstall

Compliance topic shop/redact

Arrives 48 hours after the uninstall.

Notice that a bulk export finished

Webhook bulk_operations/finish, plus polling

Described in the page on bulk operations.

Timeline

Date Change

2024-07

Webhook filter introduced with API version 2024-07.

2025-04-01

New public apps must use GraphQL only; webhook subscriptions are created with the GraphQL mutation or the TOML file.

2025-08-08

webhookSubscriptionCreate and webhookSubscriptionUpdate accept HTTPS, Pub/Sub and EventBridge URIs through the uri field; the endpoint-specific mutations and the callbackUrl field are deprecated.

2026-01-27

Access to the Admin Event interface data older than one year is removed (unrelated to Next Gen Events).

2026-02-11

Webhook subscriptions get an optional name, returned in the X-Shopify-Name header (Admin API names from API version 2026-04).

2026-03-06

checkout_id removed from order webhooks, and id from checkouts/create and checkouts/update.

2026-05-27

Next Gen Events enter developer preview for Product and Customer.

2026-07-21

Events gain metafield triggers and the Order, Collection, InventoryItem, InventoryShipment and Location topics.

2026-08-26

Events gain Metaobject, MetafieldDefinition, MetaobjectDefinition and InventoryTransfer.

2026-09-16

Events payloads change: fields_changed becomes an object with added, updated and removed; parent triggers need the explicit .* suffix; the shopify-event-id and shopify-resource-id headers are removed; triggers become required for update.

2026-09-22

The complexity limit of an Events subscription query drops from 250 to 100 points.

2026-10-01

Next Gen Events are generally available with API version 2026-10. Order webhooks gain selling_plan_id on line items.

2026-10-12

Cart operations that leave a cart unchanged no longer trigger carts/update.

What was run and what was not

Run locally on 2026-10-27 (macOS, Node 24.21.0, Shopify CLI 5.0.0, no login, no store, only placeholder values):

  • shopify app build --no-input on the shopify.app.toml shown above (webhooks, compliance topics and an Events subscription), which succeeded, and on twelve modified copies, whose results are in the table of what the offline build checks.

  • The mutation webhookSubscriptionCreate, the query webhookSubscriptions and the Events queries for prices and collection membership, validated against the Admin GraphQL schema for 2026-10 downloaded anonymously (all valid), plus a misspelled query (rejected). The schema was also read for the topic count, the input fields and the deprecations.

  • The HMAC computation with OpenSSL, Node and Python, and the Node receiver with seven request scenarios.

  • The three React Router route handlers (compliance, orders and Events), type-checked in a copy of the official template and executed with signed requests against a local SQLite database.

Not run, because they need a Shopify account, a store or an app registered with Shopify: shopify app deploy (including --no-release validation of trigger paths and query complexity), shopify app dev, shopify app webhook trigger, any delivery sent by Shopify, retries and removal of failing subscriptions, the Dev Dashboard monitoring, Google Cloud Pub/Sub and Amazon EventBridge delivery, the overflow download of Events, and the App Store review of compliance webhooks. Those parts are described from Shopify’s documentation as read on 2026-10-27. Client secrets, client ids and shop domains in the examples are placeholders.