Blog

Shopify Webhooks: Setup, HMAC Verification, Retries and Missed Events

How Shopify webhooks work in 2026: subscribe with shopify.app.toml or GraphQL, verify X-Shopify-Hmac-Sha256, answer within 5 seconds, survive 8 retries in 4 hours and recover missed events.

18 min read
Karolis Rusenas
On this page

Shopify webhooks are HTTPS POST requests that Shopify sends to your app when something happens in a store: an order is created, a product changes, the app is uninstalled. Each one is signed with X-Shopify-Hmac-Sha256, must be answered with a 2xx within five seconds, and is retried only eight times over four hours before Shopify gives up. Most production problems with Shopify webhooks come from those last two numbers, so this guide covers setup and verification, then spends most of its time on timeouts, retries, deleted subscriptions and events that never arrive.

The short version

  1. Subscribe in shopify.app.toml (app-specific) rather than with the Admin API: Shopify deletes failing Admin API subscriptions, not app-config ones.
  2. Verify X-Shopify-Hmac-Sha256 against the raw body with your app's client secret (or the store's signing secret, for webhooks created in the admin).
  3. Return 200 within five seconds. Store the event, then process it in a background job.
  4. Deduplicate on X-Shopify-Webhook-Id, and never assume order.
  5. Reconcile: a scheduled job that queries recently updated orders catches anything the webhooks missed.

How Shopify webhook delivery works

Shopify sends each webhook as a POST with a JSON body (Content-Type: application/json) and a set of X-Shopify-* headers. Delivery succeeds only if your endpoint answers with a 2xx status inside Shopify's time limits. Everything else, including a slow response, a 500 during a deploy and a 301 redirect, is a failed delivery that Shopify will retry, and then stop retrying.

Shopify webhook delivery diagram. Direct: Shopify posts to your app with one second to connect and five seconds in total; a failed delivery is retried 8 times over 4 hours and then dropped, and subscriptions created in the admin or with the Admin API are deleted after repeated failures. Through Webhook Relay: the bucket answers 200 in milliseconds and stores the event, then delivers it to your app with durable retries for up to 30 days, an alert on the first failure and replay from the request log.

These are the limits that matter, all from Shopify's webhook documentation, checked November 2026:

LimitValue
Connection timeout1 second
Total request timeout5 seconds
SuccessAny 2xx status. A 3xx redirect is a failure, and Shopify does not follow it.
RetriesUp to 8, over 4 hours, with growing intervals (measured below)
After the last retryShopify stops. The event is not resent.
Failing subscriptionsDeleted if created in the admin or with the Admin API; kept if declared in shopify.app.toml
OrderingNot guaranteed, within a topic or across topics
DuplicatesPossible. Deduplicate on X-Shopify-Webhook-Id.
Delivery logDev Dashboard, last 7 days; it flags failure rates above 0.5%

What we measured in a test store

Shopify documents the number of retries but not when they happen, so we measured it. On 11 November 2026 we created webhooks in a development store pointing at three broken endpoints: one returning 500, one returning a 301 redirect, and one that took six seconds to answer. Then we placed orders and recorded every delivery attempt.

All three failed the same way and were retried on the same schedule, to within a second:

AttemptTime after the first attemptWait since the previous attempt
First delivery0–
Retry 11 second1 second
Retry 21 minute1 minute
Retry 33 minutes2 minutes
Retry 48 minutes5 minutes
Retry 530 minutes22 minutes
Retry 61 hour30 minutes
Retry 72 hours1 hour
Retry 84 hours2 hours

What else the test showed:

  • The redirect was not followed. The 301 endpoint pointed at a second URL that never received a request.
  • A 6-second response timed out. Shopify gave up after about five seconds and retried a second later.
  • Every retry kept the same X-Shopify-Webhook-Id, which is why it is the right key for deduplication.
  • Half the retries happen in the first ten minutes. After the 8-minute retry, an endpoint gets only four more chances in the next four hours.

Three ways to subscribe

Where a subscription is created decides who can see it, which secret signs it and whether Shopify deletes it when it keeps failing.

App configurationAdmin API (GraphQL)Shopify admin
Created inshopify.app.tomlwebhookSubscriptionCreateSettings → Notifications → Webhooks
ScopeEvery shop that installs the appOne shopOne shop, created by the merchant
Signed withApp client secretApp client secretThe store's signing secret
Deleted after repeated failuresNoYesYes
Compliance topicsYesNoNo
Best forPublic and custom appsPer-shop topics or filtersStore owners wiring a no-code integration

For an app, the configuration file is the right default. Shopify keeps failing app-config subscriptions, deploys them with shopify app deploy, and applies them to every shop. You need Shopify CLI 3.63.0 or later.

shopify.app.toml · app-specific subscriptions
[webhooks]
api_version = "2026-10"

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

[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "https://your-app.example.com/webhooks/shopify"

# Required for apps in the Shopify App Store
[[webhooks.subscriptions]]
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
uri = "https://your-app.example.com/webhooks/shopify/compliance"

Use the GraphQL Admin API when a subscription only makes sense for one shop, and remember that this kind is deleted if it keeps failing:

GraphQL · POST /admin/api/2026-10/graphql.json
mutation {
  webhookSubscriptionCreate(
    topic: ORDERS_CREATE
    webhookSubscription: { uri: "https://your-app.example.com/webhooks/shopify" }
  ) {
    webhookSubscription { id topic uri }
    userErrors { field message }
  }
}

Store owners without an app go to Settings → Notifications → Webhooks and click + Webhook. The dialog asks for an event, a format (JSON or XML), a URL and a webhook API version.

The Add webhook dialog in the Shopify admin, with Event set to Order creation, Format JSON, a URL and Webhook API version 2026-10 (Latest)

The API version defaults to unstable. Change it to the latest dated version (2026-10 at the time of writing), or the payload shape can change without warning.

The same page shows the store's signing secret. One secret signs every webhook on the page, including test sends. Use Create new secret to rotate it.

The Webhooks page in the Shopify admin: a list of order webhooks with their URLs and formats, and below it the store's current signing secret with buttons to show, copy and create a new secret

Shopify deletes these webhooks if the URL keeps returning errors. Merchants are not emailed when a webhook created this way fails. Shopify only sends those emails for webhooks created by an app.

The headers Shopify sends

HeaderWhat it carriesUse it for
X-Shopify-TopicThe topic, such as orders/createRouting to a handler
X-Shopify-Hmac-Sha256Base64 HMAC-SHA256 of the raw bodyVerifying the sender
X-Shopify-Shop-DomainThe shop's myshopify.com domainPicking the tenant and its token
X-Shopify-API-VersionThe API version that built the payloadDetecting version drift
X-Shopify-Webhook-IdOne delivery; the same value on every retry of itDeduplication
X-Shopify-Event-IdThe merchant action; shared by all deliveries it causedLinking deliveries from several subscriptions
X-Shopify-Triggered-AtWhen Shopify triggered the deliveryOrdering and staleness checks
X-Shopify-NameThe subscription's optional nameTelling subscriptions apart
X-Shopify-Testtrue on test sends, false on real eventsIgnoring test deliveries in production

Header names are case-insensitive, so read them through your framework's header API rather than matching an exact spelling. Every delivery in our test also carried User-Agent: Shopify-Captain-Hook. That is useful in access logs, but not proof of the sender: only the HMAC is.

What the payload looks like

The body is the resource itself, serialised by the API version in X-Shopify-API-Version, not an envelope with an event type. The topic lives only in the X-Shopify-Topic header, so log headers and body together. A trimmed orders/create:

JSON · orders/create (trimmed)
{
  "id": 5814793224359,
  "admin_graphql_api_id": "gid://shopify/Order/5814793224359",
  "name": "#1042",
  "email": "[email protected]",
  "created_at": "2026-11-10T09:14:03-05:00",
  "updated_at": "2026-11-10T09:14:05-05:00",
  "financial_status": "paid",
  "fulfillment_status": null,
  "currency": "USD",
  "total_price": "199.00",
  "line_items": [
    {
      "id": 14012345678,
      "sku": "TEE-BLK-M",
      "title": "T-Shirt",
      "quantity": 1,
      "price": "199.00"
    }
  ],
  "customer": { "id": 7254381903, "email": "[email protected]" }
}

Three details matter in practice. Use updated_at to discard stale updates (see duplicates and ordering). Prices are strings, not numbers, so parse them as decimals. And webhooks a merchant creates in the Shopify admin can be set to XML instead of JSON, so check Content-Type if you accept those.

Order payloads also contain personal data you might not expect. client_details.browser_ip and client_details.user_agent hold the IP address and browser of whoever placed the order. For an order created in the admin, that is the staff member's. Treat stored and logged webhook bodies as personal data.

Topics you will actually use

TopicFires whenTypical job
orders/createAn order is placedFulfilment, ERP or warehouse sync, notifications
orders/updatedAny change to an orderKeep an order mirror current
orders/paidPayment is capturedStart fulfilment only after payment
orders/cancelled, refunds/createAn order is cancelled or refundedStop fulfilment, reverse revenue
fulfillments/createAn order shipsTracking emails, status sync
customers/create, customers/updateA customer account changesCRM sync
products/updateA product or its variants changeCatalogue sync, search index
inventory_levels/updateStock at a location changesInventory sync across channels
app/uninstalledThe merchant removes your appRevoke tokens, stop jobs
customers/data_request, customers/redact, shop/redactGDPR requestsMandatory for App Store apps

That is the short list. Shopify publishes the full topic list, grouped by resource: carts, checkouts, collections, fulfillments, inventory, products (products/create, products/delete), orders (orders/fulfilled, orders/edited) and more.

To cut volume, add a filter (Shopify search syntax, API 2024-07 or later) or include_fields to a subscription. Every field the filter uses must also be listed in include_fields, and a filter that names an unknown field quietly suppresses every delivery, so test a filter with one real event before relying on it.

Verify the HMAC signature

Shopify signs every HTTPS delivery: the X-Shopify-Hmac-Sha256 header is the base64-encoded HMAC-SHA256 of the raw request body, keyed with your app's client secret. Read the body as bytes before any JSON parser touches it, and compare in constant time.

Which secret? For subscriptions created by an app (in shopify.app.toml or with the Admin API), it is the app's client secret. For webhooks created in the Shopify admin, it is the store's signing secret from the bottom of Settings → Notifications → Webhooks. The code below reads either from SHOPIFY_CLIENT_SECRET.

We ran this Express handler unchanged against our development store, behind relay forward. It accepted a real orders/create signed with the store's secret, and returned 401 for an unsigned request.

server.js · Express
import crypto from 'node:crypto'
import express from 'express'

const app = express()

// Raw bytes for this route only: a JSON parser would change them.
const rawJson = express.raw({ type: 'application/json' })

app.post('/webhooks/shopify', rawJson, async (req, res) => {
  const digest = crypto
    .createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET)
    .update(req.body)
    .digest()
  const hmac = req.get('X-Shopify-Hmac-Sha256') || ''
  const header = Buffer.from(hmac, 'base64')

  const valid = header.length === digest.length
    && crypto.timingSafeEqual(digest, header)
  if (!valid) {
    return res.sendStatus(401)
  }

  await saveEvent({
    id: req.get('X-Shopify-Webhook-Id'),
    topic: req.get('X-Shopify-Topic'),
    shop: req.get('X-Shopify-Shop-Domain'),
    triggeredAt: req.get('X-Shopify-Triggered-At'),
    body: req.body.toString('utf8'),
  })
  res.sendStatus(200) // within 5 seconds; a worker does the rest
})

The same check in Python, with Flask:

app.py · Flask
import base64, hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["SHOPIFY_CLIENT_SECRET"].encode()

@app.post("/webhooks/shopify")
def shopify_webhook():
    raw = request.get_data()  # exact bytes, before any JSON parsing
    mac = hmac.new(SECRET, raw, hashlib.sha256).digest()
    digest = base64.b64encode(mac).decode()
    received = request.headers.get("X-Shopify-Hmac-Sha256", "")
    if not hmac.compare_digest(digest, received):
        abort(401)
    # Store and acknowledge; a worker processes it later.
    save_event(request.headers.get("X-Shopify-Webhook-Id"), raw)
    return "", 200

When you rotate the client secret, Shopify can take up to an hour to start signing with the new one, so accept both secrets during that hour. To check a real payload by hand, paste it into the Shopify webhook signature verifier.

Respond within 5 seconds

Five seconds sounds generous until a handler calls the Admin API, writes to three tables and posts to Slack. Every one of those can be slow on a bad day, and a slow day is when Shopify is also sending you a burst of orders. The pattern that holds up is verify, store, acknowledge, then process:

  1. Verify the HMAC.
  2. Write the raw event to durable storage (a table, a queue, an object store) keyed by X-Shopify-Webhook-Id.
  3. Return 200.
  4. A background worker processes stored events, retries its own failures and calls the Admin API at its own pace.

This keeps the response time flat however long the business logic takes, and it means a bug in step 4 never turns into a failed delivery that Shopify counts against your subscription.

Retries, deleted subscriptions and missed events

Shopify's retry policy is short. If your endpoint fails a delivery, Shopify tries again up to eight times over four hours (the measured schedule is above). Then it stops. That has three consequences that catch teams out:

  • An outage longer than four hours loses events. A Friday-night deploy that breaks the webhook route, noticed on Saturday morning, means every order webhook from that window is gone. Shopify will not resend them.
  • Subscriptions can be deleted. If failures continue, Shopify deletes subscriptions created in the Shopify admin or with the Admin API. The app's emergency developer email gets warnings first, so make sure it reaches a person. After the endpoint is fixed, the subscription has to be created again. App-config subscriptions are kept.
  • The delivery log is short. The Dev Dashboard shows the last 7 days of deliveries. Investigate quickly, or keep your own log.

Make missed events recoverable

There are three defences, and robust apps use more than one:

  1. Reconcile on a schedule. Shopify itself says delivery is not guaranteed and recommends reconciliation jobs. Every 15 minutes or every hour, query what changed and process anything you have not seen:
GraphQL · orders updated since the last run
query RecentOrders($cursor: String) {
  orders(first: 100, after: $cursor, sortKey: UPDATED_AT,
         query: "updated_at:>'2026-11-10T09:00:00Z'") {
    edges { node { id name updatedAt displayFinancialStatus } }
    pageInfo { hasNextPage endCursor }
  }
}
  1. Deliver to a queue instead of a web server. Shopify can deliver to Google Cloud Pub/Sub (it recommends it) or Amazon EventBridge. Your consumer can then be down for hours without failing a delivery. The trade-off is running and monitoring that pipeline yourself.
  2. Put a durable buffer in front of your endpoint. Point the subscription at an endpoint that always answers fast and keeps the event until your app accepts it. That is what the second half of the diagram shows, and it is the next section.

Keep every event with a durable buffer

A Webhook Relay bucket gives Shopify a public HTTPS URL that answers 200 in milliseconds and stores each request before forwarding it. Delivery to your app then follows rules you choose instead of Shopify's:

  • Durable retries with exponential backoff for up to 30 days, so an outage of hours or days costs no events. Shopify sees a successful delivery every time, so it never counts failures against the subscription.
  • Alerts on the first failed delivery to your app, in Slack, email, Teams, Discord or PagerDuty via webhook, and an automatic resolve when deliveries recover.
  • Request logs with headers, body and every attempt, and one-click resend or bulk recovery after a fix.
  • Delivery to private networks and localhost through the relay agent, if the consumer sits behind a firewall.
  • Fan-out: send the same order webhook to the app, the warehouse system and a data warehouse loader.

We ran this in the same test. Shopify sent an order to a bucket whose destination was returning 503:

  1. The bucket answered Shopify with 200 straight away, so the subscription never recorded a failure.
  2. It retried the destination 9 times in 9 minutes, then moved the event to the durable queue.
  3. The alert policy opened an incident 11 seconds after the first failure, and emailed us.

Webhook Relay request details: the Shopify order's delivery attempts, nine failed 503 responses at increasing intervals from 0 to 9.2 minutes

When the destination came back, Recover failed delivered the stored order. The incident resolved itself a second later and sent a recovery email.

Webhook Relay alert email, error: bucket shopify-guide-test needs attention, 1 of 1 deliveries failed in the last 5 minutes Webhook Relay alert email, recovered: no failures in the last 5 minutes

Webhook Relay forwards the body and headers unchanged, so your HMAC check keeps working against the original X-Shopify-Hmac-Sha256. Treat its retries like Shopify's: deliveries are at least once, so keep the X-Shopify-Webhook-Id deduplication.

Duplicates and ordering

Shopify warns that the same webhook can arrive more than once and that order is not guaranteed. Two rules cover both:

  • Deduplicate on X-Shopify-Webhook-Id. Store it with a unique constraint when you save the event; a second insert with the same id means a duplicate you can acknowledge and skip. Keep ids for at least a day, since the retry window is four hours. If you have two subscriptions on the same topic, each delivery has its own webhook id and they share X-Shopify-Event-Id. We checked this with two orders/create subscriptions in a test store: one order, two webhook ids, one event id.
  • Never let an older update overwrite a newer one. Compare the payload's updated_at (or X-Shopify-Triggered-At) with what you already stored, and ignore anything older. An orders/updated can arrive before the orders/create for the same order, so upsert rather than insert.

That last case is not rare. In our test store, the very first order sent orders/updated and orders/paid about 0.7 seconds before orders/create, and they arrived in that order. A handler that inserts on orders/create and updates on orders/updated would have tried to update an order it had not created yet.

For the general patterns behind both rules, see webhook retries and idempotency.

Test Shopify webhooks locally

You need to see real requests before you write the handler, and your handler needs real requests while you build it.

  1. Inspect. Open a free Webhook Bin, use its URL as the subscription uri, and trigger an event in a development store. The bin shows every header and the exact body. The Shopify webhook tester guide walks through it.
  2. Forward to localhost. Create a bucket, use its URL in the subscription, and run the agent:
shell · forward Shopify webhooks to localhost
relay forward --bucket shopify \
  http://localhost:3000/webhooks/shopify

The bucket URL never changes, so you set the subscription once and restart your dev server as often as you like. For a full Flask example, see receive Shopify webhooks on localhost.

For a webhook created in the Shopify admin, open Settings → Notifications → Webhooks, click the row's menu and choose Send test. Test sends are signed like real ones and carry X-Shopify-Test: true. For an app, send a test event with shopify app webhook trigger --topic orders/create --address <url>. Test events are sample payloads, not real store events, so confirm the payload against a real one from a development store before you rely on a field.

Troubleshooting

SymptomLikely causeFix
401 from your own HMAC checkBody was parsed before hashing, or the wrong secretHash the raw bytes; use the client secret of the app that owns the subscription, or the store's signing secret for admin webhooks
Deliveries time outHandler does work before answeringStore and return 200; process in a worker
Subscription vanishedAdmin or Admin API subscription deleted after repeated failuresFix the endpoint, recreate it, or move it to shopify.app.toml
Webhooks stopped after a redirectA 301/302 counts as a failureUse the final URL, including the trailing slash your server expects
Same order processed twiceRetry or duplicate deliveryUnique constraint on X-Shopify-Webhook-Id
Order data goes backwardsOut-of-order deliveryCompare updated_at before writing
Filtered subscription sends nothingFilter uses a field missing from include_fields, or an unknown fieldAdd the field to include_fields; test with a real event
Events missing after an outageRetries ran out after 4 hoursRun the reconciliation query for the outage window

Build it with an AI coding agent

Add a production-ready Shopify webhook endpoint
Paste into Claude Code, Cursor, Codex or any agent with shell and web access.

Add a Shopify webhook endpoint to this app.

Step 1: Declare subscriptions for orders/create, orders/updated and app/uninstalled in shopify.app.toml (api_version 2026-10), plus the three compliance topics, all pointing at /webhooks/shopify.

Step 2: In the route, read the raw request body before any JSON parsing. Verify X-Shopify-Hmac-Sha256 as base64 HMAC-SHA256 of the raw body with SHOPIFY_CLIENT_SECRET, compared in constant time. Return 401 if it fails.

Step 3: Insert the event into a webhook_events table with a unique constraint on X-Shopify-Webhook-Id, storing topic, shop domain, X-Shopify-Triggered-At and the raw body. On a duplicate key, return 200 without doing anything else. Then return 200. Do no other work in the request.

Step 4: Add a background worker that processes unprocessed events, upserts orders and ignores any payload whose updated_at is older than the stored row.

Step 5: Add a scheduled reconciliation job that queries orders updated since the last successful run with the GraphQL Admin API and processes any it has not seen.

Step 6: Add tests for a valid signature, a tampered body, a duplicate webhook id and an out-of-order update.

Next steps

Frequently asked questions

How long does Shopify wait for a webhook response?

Shopify allows one second to open the connection and five seconds for the whole request. Anything other than a 2xx response inside that time, including a 3xx redirect, counts as a failed delivery. Return 200 as soon as the request is verified and stored, and do the real work in a background job.

How many times does Shopify retry a failed webhook?

Shopify retries a failed webhook delivery up to eight times over four hours, waiting longer between each attempt. In our test store the retries came 1 second, 1 minute, 3 minutes, 8 minutes, 30 minutes, 1 hour and 2 hours after the first attempt, and the last one 4 hours after the first. After that it stops trying, and the event is not sent again. If failures continue, Shopify deletes subscriptions that were created in the Shopify admin or with the Admin API. Subscriptions declared in the app configuration file are not deleted.

Why did my Shopify webhook subscription disappear?

Shopify deletes a webhook subscription that keeps failing if it was created in the Shopify admin (Settings, Notifications, Webhooks) or through the Admin API. The app's emergency developer email receives warnings first. Fix the endpoint, then recreate the subscription, or move it into shopify.app.toml, where failing subscriptions are kept.

How do I verify a Shopify webhook?

Compute an HMAC-SHA256 of the raw request body with the signing key as the key: your app's client secret for app subscriptions, or the store's signing secret (shown in Settings, Notifications, Webhooks) for webhooks created in the Shopify admin. Then base64-encode the result, and compare it in constant time with the X-Shopify-Hmac-Sha256 header. Use the exact bytes Shopify sent: parsing the JSON and serialising it again changes the bytes and the signature will not match.

Are Shopify webhooks guaranteed to arrive?

No. Shopify's documentation says webhook delivery is not always guaranteed, ordering is not guaranteed within a topic, and the same webhook can arrive more than once. Deduplicate on the X-Shopify-Webhook-Id header, order by X-Shopify-Triggered-At or the resource's updated_at, and run a reconciliation job that queries the Admin API for recently updated orders or products.

What is the difference between X-Shopify-Webhook-Id and X-Shopify-Event-Id?

X-Shopify-Webhook-Id identifies one delivery and stays the same when Shopify retries it, so it is the key for deduplication. X-Shopify-Event-Id identifies the merchant action and is shared by every delivery that action produced, for example when two subscriptions listen to the same topic.

Can I receive Shopify webhooks on localhost?

Not directly, because Shopify needs a public HTTPS URL. Use a tunnel or forwarder: shopify app dev starts one for app development, and tools such as Cloudflare Tunnel, ngrok or Webhook Relay give your machine a public URL. Prefer one whose URL stays the same between restarts, so you don't have to edit the subscription every time you start developing.