Web Development

Shopify Next Gen Events: How to Migrate Off Classic Webhooks Without Losing a Delivery

Shopify's new event system makes most apps cheaper to run. It also makes it easier than ever to stop receiving something you rely on and not notice for weeks.

That trade sits at the centre of this migration. Classic webhooks are noisy but honest: something changed on the resource, so you hear about it, and your handler works out what. Next Gen Events flips the contract. You declare in advance exactly which field changes you care about, what data should come with them, and which results should be dropped. Shopify then does precisely that. If the declaration is wrong, nothing errors. The deliveries simply never arrive.

Next Gen Events became generally available on 1 October 2026 with API version 2026-10. Classic webhooks still work, nothing has a sunset date yet, and both systems can run inside the same app. So there is no deadline forcing anyone's hand. That is the best possible time to migrate, because you can do it one workflow at a time and prove each step before you remove the old one.

This guide covers how a subscription is built, where deliveries get silently suppressed, the limits that will reject your config at deploy time, and the order we would move workflows in. Every technical detail below was checked against Shopify's documentation for API version 2026-10 on 19 October 2026.

What Next Gen Events actually changes

Next Gen Events replaces "a resource changed" with "this specific field changed, and here is the data you would have fetched next". That is the whole idea, and it changes three things for an app.

First, deliveries become targeted. Instead of subscribing to products/update and receiving a payload every time anything on a product moves, you subscribe to the exact field paths that matter, such as a variant's price or a product's status. Shopify's GA announcement describes this as fewer deliveries, richer payloads and fewer follow-up API calls.

Second, the payload carries its own context. Each delivery includes a fields_changed summary listing what was added, updated or removed, plus the result of a GraphQL query you define. For most handlers, that removes the "receive webhook, then call the Admin API to find out what actually happened" round trip.

Third, configuration moves into shopify.app.toml. Subscriptions are declared under an [events] section with their own API version, deployed with the rest of the app configuration, and validated at deploy time.

Coverage at GA is 18 topics across merchandising, customers and companies, orders and fulfilment, inventory and locations, content, and custom data. That includes Product, Collection, Customer, Company, Order, FulfillmentOrder, Refund, Return, InventoryItem, Location, Article, Page, Metaobject and the metafield definition types. Anything Events does not cover yet stays on classic webhooks, which is one more reason to plan for a period where both systems run side by side.

Why the real migration risk is suppression, not configuration

The arguable claim of this post is simple: in a Next Gen Events migration, a config that deploys cleanly is not evidence that it works. The dangerous failures do not throw errors. They drop deliveries.

Classic webhooks fail loudly. If your endpoint is down, deliveries fail and show up in the logs. If your handler crashes, you see the exception. The surface area of "silently missing data" was mostly limited to the delivery itself going wrong.

Next Gen Events adds two new places where a change can happen in the store and your app never hears about it, both by design:

  • Triggers. An update subscription only fires for the field paths you list. Leave one out and changes to that field produce nothing.

  • Query filters. A filter evaluates the query result and suppresses the delivery when it is false. Filter on the wrong condition and you suppress the exact transition you needed to see.

Both behave exactly as documented. Neither appears as a failure in your delivery logs, because from Shopify's side nothing failed. That is why the rest of this guide spends more time on what you will not receive than on what you will.

The three parts of a subscription: triggers, query and filter

Every Events subscription answers three questions, and keeping them separate in your head prevents most mistakes.

Triggers answer "which changes matter?" They are dot-notation field paths such as product.status or product.variants.price. They narrow update deliveries to specific field changes. Parent triggers ending in *, such as product.variants.*, also fire for everything nested beneath them.

The query answers "what data do I need when it fires?" It is a GraphQL Admin API operation that Shopify runs after a qualifying change. Its variables, such as $productId and $variantsId, are filled in from the change itself, and its result arrives in the delivery's data field.

The filter answers "which results should be delivered at all?" A query_filter is an expression evaluated against the query result. If it evaluates to false, the delivery is suppressed. It requires a query, and any field it references must be selected in that same subscription's query.

Shopify's optimisation guide puts the division of labour plainly: use triggers to detect changes and use the filter only to test a current business condition. Mixing those roles, for example by using a filter to approximate a trigger that does not exist, is where suppression bugs come from.

A complete subscription, line by line

The fastest way to understand the format is to read one real subscription end to end. This one watches price and compare-at-price changes on variants and sends back the data a pricing or feed sync would need.

[events]
api_version = "2026-10"
 
[[events.subscription]]
handle = "variant-pricing"
topic = "Product"
actions = ["update"]
triggers = [
  "product.variants.price",
  "product.variants.compareAtPrice"
]
uri = "/events/variant-pricing"
query = """
query variantPricing($variantsId: ID!) {
  productVariant(id: $variantsId) {
    id
    sku
    price
    compareAtPrice
    product {
      id
      status
    }
  }
}
"""

Going through it field by field, using the app configuration reference:

  • api_version under [events] is required. It sets the version used to validate and run every subscription query. Developer preview users need to change this from unstable to 2026-10, including any subscription-level overrides.

  • handle is required and must be unique, using letters, numbers, underscores or hyphens, up to 50 characters. It comes back in both the payload and the headers, which makes it the natural routing key in your handler.

  • topic is the resource, here Product.

  • actions is any combination of create, update and delete.

  • uri can be an HTTPS URL, a relative path on your app's domain, or a Google Cloud Pub/Sub or Amazon EventBridge destination.

  • triggers is optional, but without it an update subscription fires for every supported field change on the resource. That is classic webhook behaviour with extra steps.

  • query and query_filter are optional.

Notice what the query asks for. It goes straight to the changed variant using $variantsId rather than loading the product and paging through variants(first: 100). That choice matters for both correctness and the complexity limit, and both come up again below.

A note on tooling versions: at the time of writing, the GA announcement and the migration guide quote different minimum Shopify CLI versions. Run shopify version, then check the current requirement in the getting started guide on the day you deploy rather than trusting either number.

Reading a delivery: fields_changed, query_variables and data

A delivery tells you what changed and what is true now, and those are deliberately separate fields.

The delivery structure guide lists the payload as topic, action, handle, fields_changed, query_variables, and, when you configured a query, data or errors. A trimmed delivery for the subscription above looks like this:

{
  "topic": "Product",
  "action": "update",
  "handle": "variant-pricing",
  "fields_changed": {
    "added": [],
    "updated": [
      "product[id: 'gid://shopify/Product/123'].variants[id: 'gid://shopify/ProductVariant/456'].price"
    ],
    "removed": []
  },
  "query_variables": {
    "productId": "gid://shopify/Product/123",
    "variantsId": "gid://shopify/ProductVariant/456"
  },
  "data": {
    "productVariant": {
      "id": "gid://shopify/ProductVariant/456",
      "sku": "TEE-BLK-M",
      "price": "24.00",
      "compareAtPrice": "30.00",
      "product": { "id": "gid://shopify/Product/123", "status": "ACTIVE" }
    }
  }
}

Three details in that payload change how a handler should be written.

fields_changed always contains added, updated and removed arrays, even when they are empty. Each entry is a path with the IDs embedded. Adding or removing a variant arrives as action: "update" with the path in added or removed, not as a separate create or delete on the variant.

query_variables is a flat map of the IDs that were available for this change. IDs bubble up the ownership hierarchy, so a variant change also supplies productId. On a delete, this map is often the only reliable way to identify the resource, because a query for a deleted node may return null.

data reflects the state when the query ran, not a snapshot of the moment the change happened. Shopify's migration guide gives returns as an example where the queried status can differ from the state at the time of the event. Treat fields_changed as "what happened" and data as "what is true now", and do not assume the two describe the same instant.

There is also an errors field, present only when the query fails to execute. Queries run with your app's access scopes, and fields the app cannot read may come back as null rather than raising an error. A handler that checks errors but not unexpected nulls will miss scope problems.

Triggers decide what you hear about

The trigger list is where most coverage gaps start, and the rules are stricter than they look.

Start with the obvious one: only listed paths fire. The Product topic reference lists more than fifty triggers, from product.title and product.status through variant prices, barcodes, options, swatches, media, bundle components and metafields. If your handler used to react to any product update and you now list three triggers, everything outside those three has gone quiet.

The second rule is less obvious. Every query variable must be available for every trigger in the subscription. Product-level triggers such as product.title supply only productId. Variant triggers such as product.variants.price supply productId and variantsId. A query that needs $variantsId therefore cannot share a subscription with product.title. Shopify validates this at save time, so the error is visible, but the fix (splitting into two subscriptions) changes how many deliveries you receive and how your handler routes them.

Third, watch for overlapping prefixes. A parent trigger like product.variants.* already includes product.variants.price. Listing both in one subscription adds nothing. Listing them in two subscriptions means a single price change produces two deliveries, each with its own ID.

Fourth, some things you might expect to trigger on do not exist. Calculated fields such as product.description and auto-updated timestamps such as product.updatedAt are not valid triggers. There is also no inventory quantity trigger on Product at all. Stock changes live on the InventoryItem topic, through its inventory-level triggers. An app that synced stock from products/update needs a new subscription on a different topic, not a new trigger on the old one.

The working rule we use: for every field your current handler reads, write down the trigger that will fire when it changes. If you cannot name one, that field is not covered yet.

Filters decide what you never hear about

A query_filter is the single easiest way to lose data in this migration, because it suppresses deliveries on purpose.

The syntax is straightforward. Shopify's example is product.status:'ACTIVE', which only delivers when the queried product is active. The filter runs after the query, needs the referenced fields to be selected in that query, and returns nothing to your app when it evaluates to false.

Now take an app that keeps a feed of active products. Filtering on active status looks like a sensible way to cut noise. Follow what happens when a merchant archives a product:

  1. The product's status changes from ACTIVE to ARCHIVED.

  2. The product.status trigger fires.

  3. The query runs and returns status: ARCHIVED.

  4. The filter evaluates to false, so the delivery is suppressed.

  5. Your feed never learns the product left, and keeps advertising it.

Shopify's optimisation guide calls out this exact case: a filter on active status would suppress the deactivation delivery. The general rule is to test both directions of every state change your app tracks. If a transition out of the filtered state matters, either drop the filter or handle that transition in a separate, unfiltered subscription.

Two related traps from the migration guide are worth knowing before you write any filter. Selecting a status field in a query does not filter anything on its own, so an orders/paid replacement needs triggers and a filter, not just displayFinancialStatus in the query. And the customer enable and disable cases return current state, so selecting state will not restrict deliveries to that transition.

The 100-point complexity limit

Every Events subscription query is capped at 100 complexity points, and Shopify checks this at deploy time without running the query.

The default costs come from the Admin API's cost calculation rules: scalar and enum fields cost nothing, object fields cost one point, and connections cost according to their first or last argument. Some fields carry custom costs, so counting fields by hand only gives you an estimate. A broad query that loads an order with its line items, fulfilments and transactions, or a product with all variants and media, can pass 100 quickly. The failure message reports the calculated score against the maximum, so it is clear when it happens.

Three habits keep queries under the limit and make them more correct at the same time:

  • Query the changed node directly. If the trigger supplies variantsId, query productVariant(id: $variantsId) rather than the product's variant connection.

  • Treat first: N as partial coverage. A connection like variants(first: 50) can leave out the very variant that changed if it falls outside that page. A page size limits what comes back. It is not a complexity budget and it is not a complete snapshot.

  • Split rather than widen. Two small subscriptions with distinct handles are usually better than one large one. Each delivery gets smaller, and you stop reloading data that did not change.

Validate before release with shopify app deploy --no-release. It checks every Events query against the limit and creates an app version without releasing it.

Verifying and deduplicating every delivery

The delivery mechanics will feel familiar if you have built classic webhook handlers, and the same discipline applies. If you want the wider context on endpoint hardening, our post on API security and the OWASP API Top 10 covers the general rules.

For HTTPS deliveries, the verification guide gives the requirements:

  • Each request carries a base64-encoded Shopify-Hmac-Sha256 header, computed from the raw request body with your app's client secret. Verify it before processing. Pub/Sub and EventBridge deliveries do not need HMAC verification.

  • After rotating the client secret, the signature can take up to an hour to reflect the new secret, so accept both during a rotation window.

  • Duplicates can happen, for example after a timeout and retry. Prefer idempotent processing. Where that is not possible, store each Shopify-Webhook-Id and skip any you have already seen, returning success.

  • If several subscriptions match the same change, each gets its own delivery with its own Shopify-Webhook-Id. Deduplicating by that header will not merge them.

The troubleshooting guide adds the timing rules. Your endpoint has five seconds to respond, any 2xx status counts as success, and Shopify retries failures automatically. The guide does not publish a retry count or window. It does say that if an endpoint keeps failing, deliveries for that subscription stop and you may need to reconcile missing data.

Five seconds is not long enough to call a third-party API, write to several tables and send an email. Verify, record, queue, respond. Here is that shape in Node with Express:

import crypto from "node:crypto";
import express from "express";
 
const app = express();
const SECRET = process.env.SHOPIFY_API_SECRET;
 
function validHmac(rawBody, header) {
  if (!header) return false;
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(rawBody)
    .digest();
  const received = Buffer.from(header, "base64");
  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(received, expected)
  );
}
 
app.post(
  "/events/:route",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!validHmac(req.body, req.get("Shopify-Hmac-Sha256"))) {
      return res.sendStatus(401);
    }
 
    const deliveryId = req.get("Shopify-Webhook-Id");
    const isNew = await deliveries.insertIfAbsent(deliveryId, {
      shop: req.get("Shopify-Shop-Domain"),
      handle: req.get("Shopify-Handle"),
      triggeredAt: req.get("Shopify-Triggered-At"),
    });
 
    if (isNew) {
      await queue.publish("shopify-events", {
        deliveryId,
        body: req.body.toString("utf8"),
      });
    }
 
    res.sendStatus(200);
  }
);

deliveries.insertIfAbsent should be a single atomic operation, such as an insert against a unique index, so two copies of the same delivery arriving together cannot both pass. The worker that consumes the queue does the real processing and should itself be idempotent, because a crash between processing and acknowledging will replay a message. If you build on Shopify's React Router app template, authenticate.webhook(request) handles the HMAC check for you before your code runs.

Ordering, delays and why you still need reconciliation

Next Gen Events does not promise ordered or timely delivery, and an app that assumes either will drift.

The documentation does not state an ordering guarantee. The optimisation guide says targeted subscriptions can fail or arrive out of order, and the troubleshooting guide says deliveries can arrive late without giving a maximum delay. Two consequences follow.

Do not let a late delivery overwrite newer state. Because data reflects the query result rather than the original change, it is usually current. But if you store values derived from it, compare a source timestamp such as the resource's updatedAt before writing, and discard anything older than what you already hold. Shopify-Triggered-At tells you when Shopify triggered the delivery, which is useful for spotting stale ones.

Run a reconciliation job. Shopify's own guidance is to keep a separate process that uses targeted Admin API queries or bulk operations to repair missed work and page through complete collections. That covers the delivery that exhausted its retries during an outage, the transition a filter suppressed, and the trigger you forgot. It is the safety net that makes a targeted event system safe to depend on, and it is the same pattern we recommend in any multi-tenant SaaS architecture where events feed per-tenant state.

How often to reconcile depends on how costly drift is. A nightly bulk comparison is enough for most catalogue syncs. Inventory and order state usually justify something tighter.

Large payloads and payload_url

When a delivery exceeds the channel's size limit, Shopify sends a pointer instead of the payload, and a handler that does not check for it will read empty fields.

The limits are 5 MB for HTTPS, 10 MB for Google Cloud Pub/Sub and 256 KB for Amazon EventBridge. Over the limit, Shopify stores the full content and delivers a small payload with topic, action, handle, payload_url, payload_size_bytes and expires_at. The fields_changed, query_variables and data fields only exist in the stored content.

So the first thing a worker should do is check for payload_url. If it is present, download the content before expires_at, because the URL is short-lived. For HTTPS, verify the HMAC on the small payload you received, not on the downloaded content. EventBridge users should take the 256 KB ceiling seriously, since a modest product query with metafields can approach it.

Mapping classic webhook topics: the gotchas worth knowing first

Most classic topics map cleanly onto an Events topic plus a trigger. A handful do not, and Shopify's migration guide lists them. These are the ones most likely to affect a typical app:

  • Field names change shape. REST payload fields generally move from snake_case to camelCase, but not always one to one. admin_graphql_api_id becomes id, body_html becomes descriptionHtml, and financial_status becomes displayFinancialStatus. Some REST fields have no GraphQL equivalent.

  • Tags are the full current set. On Events, tags is every tag the resource has now, not the ones added or removed. If your logic reacts to a specific tag being added, compare against stored state.

  • Order status webhooks need triggers and filters. orders/paid, orders/fulfilled and orders/partially_fulfilled do not have one-line equivalents.

  • Inventory lives on InventoryItem. There is no stock quantity trigger on Product. For a location being disconnected, subscribe to inventoryItem.inventoryLevel.isActive and check for false.

  • Fulfilment IDs differ. A classic fulfilment ID is not a fulfilment order ID. Query the parent through fulfillmentOrderId.

  • Returns need care. A create delivery cannot reliably tell you whether a return was requested or already approved. Use the return.requestApprovedAt trigger for approvals.

  • Some topics stay on webhooks. In 2026-10 there is no Events equivalent for the reverse fulfilment order disposition webhook, so keep reverse_fulfillment_orders/dispose where it is.

Deletes are the simplest case. No query is needed, and query_variables identifies the deleted resource.

Which workflows to move first

Move the workflows where an Events payload removes a follow-up API call and where a missed delivery is cheap to repair. Leave the ones where a missed delivery costs money until last.

A practical order:

  1. High-volume, read-heavy handlers. A products/update handler that immediately fetches the product to see what changed is the clearest win. Targeted triggers cut volume and the query removes the fetch.

  2. Catalogue and content sync. Product, collection, metafield, metaobject and page changes feeding a search index, a product feed or a headless front end. Our post on structured content for AI search covers why keeping that layer accurate matters.

  3. Customer and company data. Lower volume, but useful once the pattern is proven.

  4. Orders, refunds, returns and inventory last. These have the most topic-specific gotchas and the highest cost when something is missed. Move them once reconciliation is running and you have a few weeks of data from the earlier workflows.

Measure as you go. Shopify suggests tracking delivery volume, payload size and follow-up API calls during migration rather than assuming savings, and the GA post does not claim specific numbers. Optimised subscriptions can even produce more deliveries than before, because one merchant action may touch both product and variant data. Each delivery is smaller and cheaper, which is usually the better trade, but only measurement will tell you for your app.

Running both systems side by side

During migration, classic webhooks and Events subscriptions can live in the same app, and that overlap is what makes the move safe.

The pattern is a shadow period. Deploy the Events subscription alongside the existing webhook, but have the new handler record what it would do rather than doing it. Compare the two streams for a few days across real merchant activity: every change the webhook caught should appear in an Events delivery, and every Events delivery should map to something the webhook path processed. Gaps point straight at a missing trigger or an over-eager filter.

Two things to plan for during the overlap. The two systems produce separate deliveries with separate IDs, so Shopify-Webhook-Id will not deduplicate across them. If both paths are live and writing, rely on idempotent writes keyed on the resource and its updatedAt. And when you cut over, switch the write path to Events first, keep the webhook subscribed but inert for a short period, then remove it. Removing first leaves you with no way back.

A test plan before you release

A subscription that deploys is validated. A subscription that works has been tested against every transition your app depends on. Before releasing, we run through this on a development store:

  • Validate with shopify app deploy --no-release and fix any complexity or variable errors.

  • Confirm every trigger exists in the Events reference for 2026-10 and supplies every variable the query uses.

  • Confirm the app has the access scopes each topic requires, and look for unexpected nulls in data.

  • Exercise create, update and delete, and inspect data, errors, fields_changed and query_variables for each.

  • Remove a relationship (a product from a collection, a variant from a product) and check the delivery arrives.

  • For every filter, move the resource into and out of the filtered state, and confirm you see every transition you need.

  • Send the same delivery twice and confirm nothing is processed twice.

  • Hold a delivery past five seconds in a test and confirm the retry is handled cleanly.

  • Run the reconciliation job and confirm it finds and repairs a change you deliberately suppressed.

The Dev Dashboard helps with the last mile. Under your app's Logs you can see deliveries from the past seven days, filtered by topic, status and shop, with response codes, attempt numbers and payload sizes. The Overview shows failed delivery rate and 90th-percentile response time. Log data can lag by several minutes, so do not panic if a test delivery takes a moment to appear.

Every check above, along with the planning and cut-over steps, is in the printable worksheet below with pass criteria and a readiness score, so you can tick through it per workflow.

Frequently asked questions

Do I have to migrate from classic webhooks to Next Gen Events now?

No. Classic webhooks keep working, and as of API version 2026-10 Shopify has not announced a sunset date. Both can run in the same app, so you can migrate one workflow at a time. The advantage of starting early is that you can test thoroughly without a deadline.

Which Shopify topics does Next Gen Events support?

At GA there are 18 topics: Product, Collection, Customer, Company, Order, FulfillmentOrder, Refund, Return, InventoryItem, InventoryShipment, InventoryTransfer, Location, Article, Blog, Page, MetafieldDefinition, Metaobject and MetaobjectDefinition. Each has its own triggers, query variables and access scopes, listed in the Events API reference.

How do I avoid processing the same Shopify event twice?

Make processing idempotent where you can, so receiving a delivery twice has the same effect as receiving it once. Otherwise, store each Shopify-Webhook-Id atomically and skip any you have already seen, while still returning a 2xx response. Remember that separate subscriptions matching the same change produce separate IDs.

What happens if my Events query is too complex?

Deployment fails. Each subscription query is capped at 100 complexity points and checked at deploy time. The error reports the calculated score against the maximum. Query the changed node directly using the variable the trigger supplies, and split large subscriptions into smaller ones.

Does the data in a delivery show the state at the moment of the change?

Not necessarily. The query runs after the change, so data reflects state when the query ran. Use fields_changed to understand what happened, data for current state, and source timestamps such as updatedAt to avoid overwriting newer information with a late delivery.

Can I send Events to Pub/Sub or EventBridge instead of an HTTPS endpoint?

Yes. A subscription's uri can point to an HTTPS endpoint, a relative path on your app, Google Cloud Pub/Sub or Amazon EventBridge. Payload limits differ by channel: 5 MB for HTTPS, 10 MB for Pub/Sub and 256 KB for EventBridge, with larger payloads delivered as a short-lived payload_url.

Keep reading

All articles