Skip to main content

Migrate from webhooks

Events uses the same delivery infrastructure as classic webhooks, with field-level triggers and data selected by a GraphQL query. Use this guide to translate the REST-shaped fields your webhook handler reads into GraphQL Admin API selections, then design an Events subscription around the changes your app needs.

The starter queries below map classic webhook fields to their GraphQL equivalents where available. Each topic explains renamed, restructured, and unavailable fields. These queries are a starting point for field mapping. They don't configure a subscription or reproduce the conditions that caused the classic webhook to fire.

Developer preview

Events is in developer preview on the unstable API version, available today for a subset of topics. Use it for early testing ahead of a stable release and broader topic coverage. For topics not yet supported, use webhooks alongside Events in the same shopify.app.toml. As Events expands topic coverage, it will become the primary subscription mechanism.


  • Use Shopify CLI version 3.92 or higher. Run shopify version to check your version, and follow the upgrade instructions if needed.
  • Identify the classic webhook topics your app uses, the changes that cause them to fire, and the fields your handler reads.
  • Check the Events reference for supported topics, triggers, query variables, and required access scopes.

Keep classic webhooks for these unsupported topics:

  • customer.joined_segment
  • customer.left_segment
  • customers/data_request
  • customers/marketing_consent/update
  • customers/merge
  • customers/redact
  • inventory_shipments/add_items
  • inventory_shipments/receive_items
  • inventory_shipments/remove_items
  • inventory_shipments/update_item_quantities

Anchor to Step 1: Translate your webhook fieldsStep 1: Translate your webhook fields

Choose your classic webhook topic below, and use its starter query to map the fields your handler needs. REST field names generally change from snake_case to camelCase, but some fields have different structures or no GraphQL equivalent.

Complexity points estimate the work required by a query based on its selected fields and connection sizes. By default, scalar and enum fields cost zero points, object fields cost one point, and connection costs depend on first and last. Some fields have custom costs, so use the GraphQL Admin API cost calculation rules to estimate complexity and validate your subscription query to confirm it meets the limit.

Events subscription queries have a complexity limit of 100 points. The broad starter queries can exceed that limit, especially when they select large connections or nested objects. Optimize your query before using it in a subscription. Connection arguments such as first: 250 limit the number of records returned. A page size isn't a complexity budget or a guarantee of a complete result.

For delete actions, there's no starter query: the resource no longer exists. Use the resource ID in query_variables to identify what Shopify deleted. See Events delivery structure.

Continue using classic webhooks in production during the Events developer preview. Test supported topics alongside your existing webhooks, and keep classic webhooks for topics Events doesn't support.

Use the Collection reference to choose supported triggers and query variables for your app. Collection Events require the read_products access scope.

collections/create

Use this query to translate the fields from a collection creation webhook.

Field differences:

  • body_html becomes descriptionHtml.
  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • published_at and published_scope don't map to scalar Collection fields. Use publication-related GraphQL Admin API fields when you need publication state.
  • sort_order becomes sortOrder.
  • template_suffix becomes templateSuffix.
collections/update

Use the same collection field mapping for updates. Identify whether your handler needs collection details, product membership changes, or both, then choose supported triggers in the Collection reference.

collections/delete

No query is needed for a deleted Collection. Use query_variables.collectionId to identify it.

Starter query: collections/create

query collection_create_starter($collectionId: ID!) {
collection(id: $collectionId) {
id
handle
title
updatedAt
descriptionHtml
sortOrder
templateSuffix
}
}

Use the Customer reference to choose supported triggers and query variables for your app.

customers/create

Use this query to translate the fields from a customer creation webhook.

Field differences:

  • currency has no Events equivalent. The webhook derives it from store defaults.
  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • Address customer_id, country_name, and default fields have no Events equivalents.
  • Address id values are GID strings instead of integers.
  • state uses uppercase enum values.
customers/update

Use this query to translate customer fields, then choose triggers for the changes your handler needs.

Field differences:

  • currency has no Events equivalent. The webhook derives it from store defaults.
  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • Address customer_id, country_name, and default fields have no Events equivalents.
  • Address id values are GID strings instead of integers.
  • state uses uppercase enum values.
customers/disable

This webhook represents a customer account becoming disabled. The query returns the current customer state; selecting state doesn't restrict delivery to that transition. Review state triggers and filtering when you design your subscription.

Field differences:

  • currency has no Events equivalent. The webhook derives it from store defaults.
  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • Address customer_id, country_name, and default fields have no Events equivalents.
  • Address id values are GID strings instead of integers.
  • state uses uppercase enum values. The query can return any current state.
customers/enable

This webhook represents a customer account becoming enabled. The query returns the current customer state; selecting state doesn't restrict delivery to that transition. Review state triggers and filtering when you design your subscription.

Field differences:

  • currency has no Events equivalent. The webhook derives it from store defaults.
  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • Address customer_id, country_name, and default fields have no Events equivalents.
  • Address id values are GID strings instead of integers.
  • state uses uppercase enum values. The query can return any current state.
customer.tags_added

Use this query to retrieve the current tag set. Determine what changed by comparing it with your stored state. A query filter evaluates the current data and can't distinguish a tag addition from a removal.

Field differences:

  • occurredAt approximates updatedAt on Customer. They match for tag events, but updatedAt advances on any customer change.
  • tags in Events is the full current tag set, not just the added tags.
customer.tags_removed

Use this query to retrieve the remaining tag set. Determine what changed by comparing it with your stored state. A query filter evaluates the current data and can't distinguish a tag removal from an addition.

Field differences:

  • occurredAt approximates updatedAt on Customer. They match for tag events, but updatedAt advances on any customer change.
  • tags in Events is the remaining tag set after removal, not the removed tags.
customers/purchasing_summary

Use this query to translate purchasing summary fields. Check the Customer reference for triggers that cover the purchasing changes your handler needs.

Field differences:

  • lastOrderId becomes lastOrder { id }.
  • numberOfOrders is a string (UnsignedInt64), not an integer.
  • occurredAt approximates Customer.updatedAt. They match for purchasing events, but updatedAt advances on any customer change.
customers/delete

No query is needed for a deleted Customer. Use query_variables.customerId to identify it.

Starter query: customers/create

query customer_create_starter($customerId: ID!) {
customer(id: $customerId) {
id
createdAt
updatedAt
firstName
lastName
state
note
verifiedEmail
multipassIdentifier
taxExempt
email
phone
taxExemptions
defaultAddress {
id
firstName
lastName
company
address1
address2
city
province
country
zip
phone
name
provinceCode
countryCode
}
addresses {
id
firstName
lastName
company
address1
address2
city
province
country
zip
phone
name
provinceCode
countryCode
}
}
}

Use the InventoryItem reference to choose supported triggers and query variables for your app. InventoryItem Events require the read_inventory access scope.

inventory_items/create

Use this query to translate the fields from an inventory item creation webhook.

Field differences:

  • cost becomes unitCost { amount currencyCode }.
  • country_code_of_origin becomes countryCodeOfOrigin.
  • country_harmonized_system_codes becomes countryHarmonizedSystemCodes.nodes.
  • harmonized_system_code becomes harmonizedSystemCode.
  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • province_code_of_origin becomes provinceCodeOfOrigin.
  • weight_value and weight_unit are nested under measurement.weight as value and unit.
inventory_items/update

Use the same inventory item field mapping for updates. Choose triggers for the item fields your handler needs, and remove unused fields and connections from the query.

inventory_items/delete

No query is needed for a deleted InventoryItem. Use query_variables.inventoryItemId to identify it.

Starter query: inventory_items/create

query inventory_item_create_starter($inventoryItemId: ID!) {
inventoryItem(id: $inventoryItemId) {
id
sku
createdAt
updatedAt
tracked
requiresShipping
countryCodeOfOrigin
provinceCodeOfOrigin
harmonizedSystemCode
countryHarmonizedSystemCodes(first: 250) {
nodes {
countryCode
harmonizedSystemCode
}
}
measurement {
weight {
value
unit
}
}
unitCost {
amount
currencyCode
}
}
}

Anchor to [object Object]InventoryShipment

Use the InventoryShipment reference to choose supported triggers and query variables for your app. InventoryShipment Events require the read_inventory_shipments access scope.

inventory_shipments/create

Use this query to translate shipment fields. The line item connection is a starting point; reconcile additional pages separately if your app needs every item.

Field differences:

  • happened_at and inventory_transfer_id have no Events equivalents.
  • line_items becomes the lineItems.nodes connection.
  • tracking fields use camelCase, including arrivesAt, trackingNumber, and trackingUrl.
inventory_shipments/mark_in_transit

This webhook represents a shipment moving to IN_TRANSIT. The query returns the current status. Review status triggers and filtering to decide which transitions your app needs to process.

inventory_shipments/update_tracking

Use this query to translate tracking fields. Choose supported tracking triggers based on the changes your handler needs.

inventory_shipments/delete

No query is needed for a deleted InventoryShipment. Use query_variables.inventoryShipmentId to identify it.

Starter query: inventory_shipments/create

query inventory_shipment_create_starter($inventoryShipmentId: ID!) {
inventoryShipment(id: $inventoryShipmentId) {
id
status
tracking {
arrivesAt
company
trackingNumber
trackingUrl
}
lineItems(first: 250) {
nodes {
id
quantity
}
}
}
}

Use the Location reference to choose supported triggers and query variables for your app. Location Events require the read_locations access scope.

locations/create

Use this query to translate the fields from a location creation webhook.

Field differences:

  • active becomes isActive.
  • Address fields are nested under address.
  • country and province become address.country and address.province. Their code fields become address.countryCode and address.provinceCode.
  • country_name is also represented by address.country.
  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • legacy has no Events equivalent.
locations/update

Use the same location field mapping for updates. Choose supported triggers for the location details your handler needs.

locations/activate

This webhook represents a location becoming active. The query returns isActive; choose triggers and filtering based on the state transitions your app needs.

locations/deactivate

This webhook represents a location becoming inactive. The query returns isActive; choose triggers and filtering based on the state transitions your app needs.

locations/delete

No query is needed for a deleted Location. Use query_variables.locationId to identify it.

Starter query: locations/create

query location_create_starter($locationId: ID!) {
location(id: $locationId) {
id
name
createdAt
updatedAt
isActive
address {
address1
address2
city
zip
province
country
countryCode
provinceCode
phone
}
}
}

Use the Order reference to choose supported triggers and query variables for your app. Order Events require one of the read_orders, read_marketplace_orders, read_buyer_membership_orders, or read_quick_sale access scopes.

orders/create

Use this query to translate order fields. Its broad field selection and nested connections need optimization before you use it in an Events subscription.

Field differences:

  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • financial_status becomes displayFinancialStatus.
  • fulfillment_status becomes displayFulfillmentStatus.
  • Price fields are returned as money sets, such as totalPriceSet { shopMoney { amount currencyCode } }.
  • browser_ip becomes clientIp, buyer_accepts_marketing becomes customerAcceptsMarketing, and note_attributes becomes customAttributes.
  • order_status_url becomes statusPageUrl, referring_site becomes referrerUrl, and source_url becomes registeredSourceUrl.
  • Line items, discount applications, shipping lines, and returns are connections with nodes instead of flat arrays.
  • client_details, device_id, landing_site_ref, reference, and token have no direct Events equivalents.
orders/updated

Use the same order field mapping for updates. Choose supported triggers for the order changes your handler needs, then reduce the query to the relevant fields.

orders/cancelled

This webhook represents an order cancellation. Use cancelReason and cancelledAt for cancellation details, and check the Order reference for the corresponding change triggers.

orders/fulfilled

This webhook represents an order becoming fulfilled. The query returns displayFulfillmentStatus; selecting it alone doesn't restrict deliveries to fulfilled orders. Review fulfillment triggers and filtering.

orders/partially_fulfilled

This webhook represents an order becoming partially fulfilled. The query returns displayFulfillmentStatus; selecting it alone doesn't restrict deliveries to partially fulfilled orders. Review fulfillment triggers and filtering.

orders/paid

This webhook represents an order becoming paid. The query returns displayFinancialStatus; review the Order reference and your payment workflow to choose triggers and filtering.

orders/delete

No query is needed for a deleted Order. Use query_variables.orderId to identify it.

orders/edited

There isn't a direct field-level Events equivalent. Start with the orders/updated query and identify the supported triggers that cover the order edits your app needs.

Starter query: orders/create

query order_create_starter($orderId: ID!) {
order(id: $orderId) {
id
app { id }
clientIp
customerAcceptsMarketing
cancelReason
cancelledAt
cartToken
checkoutToken
closedAt
confirmationNumber
confirmed
email
createdAt
currencyCode
currentShippingPriceSet { ...MoneyBagFields }
currentSubtotalPriceSet { ...MoneyBagFields }
currentTotalAdditionalFeesSet { ...MoneyBagFields }
currentTotalDiscountsSet { ...MoneyBagFields }
currentTotalDutiesSet { ...MoneyBagFields }
currentTotalPriceSet { ...MoneyBagFields }
currentTotalTaxSet { ...MoneyBagFields }
customerLocale
discountCodes
dutiesIncluded
estimatedTaxes
name
displayFinancialStatus
displayFulfillmentStatus
landingPageUrl
landingPageDisplayText
physicalLocation { id }
retailLocation { id }
merchantBusinessEntity { id }
merchantOfRecordApp { id }
note
customAttributes { key value }
number
statusPageUrl
originalTotalAdditionalFeesSet { ...MoneyBagFields }
originalTotalDutiesSet { ...MoneyBagFields }
paymentGatewayNames
phone
poNumber
presentmentCurrencyCode
processedAt
referrerUrl
sourceIdentifier
sourceName
registeredSourceUrl
subtotalPriceSet { ...MoneyBagFields }
tags
taxExempt
taxLines { ...TaxLineFields }
taxesIncluded
test
totalCashRoundingAdjustment {
paymentSet { ...MoneyBagFields }
refundSet { ...MoneyBagFields }
}
totalDiscountsSet { ...MoneyBagFields }
totalOutstandingSet { ...MoneyBagFields }
totalPriceSet { ...MoneyBagFields }
totalShippingPriceSet { ...MoneyBagFields }
totalTaxSet { ...MoneyBagFields }
totalTipReceived { amount currencyCode }
totalWeight
updatedAt
staffMember { id }
billingAddress { ...MailingAddressFields }
customer { ...CustomerFields }
discountApplications(first: 250) {
nodes { ...DiscountApplicationFields }
}
fulfillments { id }
lineItems(first: 250) {
nodes {
id
currentQuantity
fulfillableQuantity
fulfillmentService { handle }
fulfillmentStatus
isGiftCard
weight { value unit }
name
originalUnitPrice
originalUnitPriceSet { ...MoneyBagFields }
product { id }
customAttributes { key value }
quantity
requiresShipping
lineItemGroup { id }
sku
taxable
title
totalDiscount
totalDiscountSet { ...MoneyBagFields }
variant { id }
variantTitle
vendor
staffMember { id }
taxLines { ...TaxLineFields }
duties {
id
countryCodeOfOrigin
harmonizedSystemCode
price { ...MoneyBagFields }
taxLines { ...TaxLineFields }
}
discountAllocations {
allocatedAmount { amount currencyCode }
allocatedAmountSet { ...MoneyBagFields }
discountApplication { ...DiscountApplicationFields }
}
}
}
paymentTerms { id }
refunds { id }
shippingAddress { ...MailingAddressFields }
shippingLines(first: 250, includeRemovals: true) {
nodes {
id
carrierIdentifier
code
currentDiscountedPriceSet { ...MoneyBagFields }
discountedPrice { amount currencyCode }
discountedPriceSet { ...MoneyBagFields }
isRemoved
phone
originalPrice { amount currencyCode }
originalPriceSet { ...MoneyBagFields }
source
title
taxLines { ...TaxLineFields }
discountAllocations {
allocatedAmount { amount currencyCode }
allocatedAmountSet { ...MoneyBagFields }
discountApplication { ...DiscountApplicationFields }
}
}
}
returns(first: 250) { nodes { id } }
}
}

fragment MoneyBagFields on MoneyBag {
shopMoney { amount currencyCode }
presentmentMoney { amount currencyCode }
}

fragment MailingAddressFields on MailingAddress {
firstName
lastName
company
address1
address2
city
province
country
zip
phone
name
provinceCode
countryCode
latitude
longitude
}

fragment CustomerFields on Customer {
id
createdAt
updatedAt
firstName
lastName
state
note
verifiedEmail
multipassIdentifier
taxExempt
email
phone
taxExemptions
defaultAddress { ...MailingAddressFields }
}

fragment TaxLineFields on TaxLine {
title
rate
price
priceSet { ...MoneyBagFields }
channelLiable
}

fragment DiscountApplicationFields on DiscountApplication {
__typename
index
allocationMethod
targetSelection
targetType
value {
... on MoneyV2 { amount currencyCode }
... on PricingPercentageValue { percentage }
}
... on AutomaticDiscountApplication { title }
... on DiscountCodeApplication { code }
... on ManualDiscountApplication { title }
... on ScriptDiscountApplication { title }
}

Use the Product reference to choose supported triggers and query variables for your app.

products/create

Use this query to translate product fields, including the variants, images, and media embedded in classic webhooks. The connections are a starting point for field mapping and need optimization before use in Events.

Field differences:

  • body_html becomes bodyHtml.
  • category becomes category { id name fullName }.
  • has_variants_that_requires_components becomes hasVariantsThatRequiresComponents.
  • The webhook's admin_graphql_api_id becomes id. Numeric REST IDs don't have an Events equivalent.
  • image (featured image) becomes featuredImage.
  • image_id on variants becomes image { id url altText }.
  • The classic webhook embeds images, media, and variants as flat arrays. The query selects them through connections with nodes. Variant id values correspond to variant_gids; connection pagination can omit variants.
  • inventory_item_id on variants becomes inventoryItem { id }.
  • inventory_policy on variants and status use uppercase enum values (DENY/CONTINUE and ACTIVE/DRAFT/ARCHIVED).
  • option1/option2/option3 on variants become selectedOptions { name value }.
  • tags returns an array instead of a comma-separated string.
  • old_inventory_quantity on variants and published_scope have no Events equivalents.
products/update

Use the same product field mapping for updates. Identify whether your handler needs product, variant, or media changes. When a supported trigger provides a child ID, query the changed child directly instead of reloading the connection. See Optimizing your subscriptions.

products/delete

No query is needed for a deleted Product. Use query_variables.productId to identify it.

Starter query: products/create

query product_create_starter($productId: ID!) {
product(id: $productId) {
id
title
handle
status
vendor
productType
bodyHtml
createdAt
updatedAt
publishedAt
templateSuffix
tags
hasVariantsThatRequiresComponents
options {
id
name
position
values
}
featuredImage {
id
altText
url
width
height
}
category {
id
name
fullName
}
variants(first: 250) {
nodes {
id
title
price
compareAtPrice
sku
barcode
position
createdAt
updatedAt
taxable
inventoryPolicy
inventoryQuantity
product { id }
image { id url altText }
inventoryItem { id }
selectedOptions { name value }
}
}
media(first: 250) {
nodes {
... on Media {
id
alt
mediaContentType
status
preview {
status
image {
id
altText
url
width
height
}
}
}
... on MediaImage {
image {
id
altText
url
width
height
}
}
... on Video {
duration
sources {
fileSize
format
height
mimeType
url
width
}
}
... on Model3d {
sources {
filesize
format
mimeType
url
}
}
... on ExternalVideo {
embedUrl
host
originUrl
}
}
}
images(first: 250) {
nodes {
id
altText
url
width
height
}
}
}
}

Anchor to Step 2: Identify what should trigger your subscriptionStep 2: Identify what should trigger your subscription

A query selects data. It doesn't determine when an Events subscription fires. For each classic webhook you want to migrate, identify the operation or field change your app reacts to and find the corresponding Events topic, action, and supported triggers.

  • For create, query the new resource using its ID.
  • For update, choose triggers for the specific changes your app needs. Every subscription whose actions include update requires triggers.
  • For delete, identify the deleted resource from query_variables instead of querying it.

Check the variables available for every action and trigger you select. A query can require a child ID only when every trigger in that subscription provides it. Split changes into separate subscriptions when they need different IDs or query roots.

For business operations such as an order becoming paid or a customer account becoming enabled, identify both the change and the resulting state that matter to your app. Use triggers to detect changes and query_filter to check the current query result. A query filter can't detect a transition by itself. If your app tracks a set of resources, then include the changes that remove resources from that set as well as those that add them.

See Delivery filtering for trigger and query filter behavior.


Anchor to Step 3: Optimize your queryStep 3: Optimize your query

Use Optimizing your subscriptions for the detailed workflow and worked examples. Apply it to each starter query before configuring your replacement subscription:

  1. Keep only the fields your handler and query_filter need. If your classic webhook uses include_fields, then use that list as your starting point.
  2. Follow the optimization guide to choose targeted queries, split subscriptions, and reconcile any data that deliveries don't include.
  3. Validate the optimized subscription to confirm that its query meets the 100-point complexity limit. Shopify checks complexity when you deploy an app version, without executing the query or waiting for an event. Use shopify app deploy --no-release to validate without releasing the version to users.

  • Subscribe to Events: Configure a subscription after you've chosen its actions, triggers, and optimized query.
  • Events delivery structure: Update your handler for GraphQL field names, query variables, and delivery metadata.
  • Troubleshoot Events: Inspect test deliveries and diagnose failures before changing your existing webhook coverage.

Was this page helpful?