Why Polling Is the Wrong Way to Watch a Shopify Store
How does your app find out an order was just placed? If the answer is "we hit the API every 60 seconds and diff the results," you're burning rate limit budget to learn things Shopify would have told you for free.
Shopify webhooks flip the model. Instead of your app asking "anything new?" thousands of times a day, Shopify sends an HTTPS POST to your endpoint the moment an event happens — an order is created, a product is updated, an app is uninstalled. You get near-real-time data, you stop wasting GraphQL rate limit headroom on empty polls, and your app reacts in seconds instead of minutes.
But webhooks come with sharp edges that don't show up in tutorials: deliveries arrive more than once, sometimes out of order, and occasionally not at all. Subscriptions can silently vanish after repeated failures. And every public App Store app must handle three mandatory privacy webhooks before review. This guide covers the full lifecycle — subscribing, verifying, retrying, scaling, and reconciling — with everything checked against current Shopify documentation. Webhooks are one slice of the platform's eventing surface; for the request/response side, our complete guide to the Shopify Admin API is the companion read.
How Shopify Webhooks Actually Work

A Shopify webhook subscription is a simple contract: you tell Shopify which topic you care about and where to send deliveries. When a matching event fires in a shop, Shopify serializes the resource (as JSON or XML) and POSTs it to your destination.
The Anatomy of a Delivery
Every delivery has three parts you'll work with constantly:
- Topic — the event type, named as
resource/event:orders/create,products/update,app/uninstalled,inventory_levels/update, and dozens more - Payload — the resource itself, e.g. the full order object for
orders/create - Headers — metadata that tells you what happened, when, and whether the request is genuine
Shopify's official webhooks documentation frames the system around four jobs: manage subscriptions, filter deliveries, understand the delivery structure, and verify each delivery. That's a useful mental model — and it's exactly how this guide is organized.
The Headers That Matter
Four headers do most of the heavy lifting in production handlers:
| Header | What it's for |
|---|---|
| X-Shopify-Topic | Identifies the event, so one endpoint can route many topics |
| X-Shopify-Hmac-Sha256 | Base64 HMAC signature — proves the request came from Shopify |
| X-Shopify-Webhook-Id | Unique delivery ID — your key for duplicate detection |
| X-Shopify-Triggered-At | When the event actually occurred (not when it was delivered) |
Bookmark that table. Signature verification and idempotency — the two things that separate toy handlers from production ones — both live in these headers.
Trimming Payloads with Filters and include_fields
You rarely need the whole resource. Two subscription-level options keep noise down:
- `filter` — only deliver events matching a condition (e.g., only orders above a certain total), so your endpoint never sees irrelevant events
- `include_fields` — only include the named fields in the payload, shrinking payload size and reducing the blast radius of any logging mistakes
Both are set per subscription, in either of the two subscription methods covered next.
Two Ways to Subscribe to Shopify Webhooks
Modern Shopify development gives you two subscription mechanisms for Shopify webhooks, and choosing correctly up front saves real pain later. (If you're coming from the REST /admin/webhooks endpoints: those still exist, but the REST Admin API is legacy — new apps should use the declarative config or GraphQL.)
App-Specific Subscriptions in shopify.app.toml
The recommended default is declarative configuration in your app's shopify.app.toml. Subscriptions defined here apply identically to every shop that installs your app, and they sync automatically when you run shopify app deploy:
[webhooks]
api_version = "2026-04"
[[webhooks.subscriptions]]
topics = ["orders/create", "orders/updated"]
uri = "https://your-app.example.com/webhooks/orders"
[[webhooks.subscriptions]]
topics = ["products/update"]
uri = "https://your-app.example.com/webhooks/products"
include_fields = ["id", "title", "updated_at", "variants"]This is infrastructure-as-code for your event layer: subscriptions live in version control, code review sees every change, and a deploy reconciles Shopify's state with your file. Shopify explicitly recommends this approach unless your configuration must differ per shop.
Shop-Specific Subscriptions via the GraphQL Admin API
When subscriptions need to vary per installation — a merchant toggles a feature that requires inventory_levels/update, for example — create them at runtime with the webhookSubscriptionCreate mutation:
mutation {
webhookSubscriptionCreate(
topic: ORDERS_CREATE
webhookSubscription: {
uri: "https://your-app.example.com/webhooks/orders"
format: JSON
}
) {
webhookSubscription {
id
topic
uri
}
userErrors {
field
message
}
}
}One modernization worth knowing: the old transport-specific mutations like eventBridgeWebhookSubscriptionCreate are deprecated. The unified webhookSubscriptionCreate now accepts any destination through its uri field — an HTTPS URL, a pubsub://{project-id}:{topic-id} URI, or an Amazon EventBridge ARN. One mutation, three transports.
Which Should You Use?
| Situation | Use |
|---|---|
| Same topics and endpoint for every shop | shopify.app.toml (declarative) |
| Topics or destinations vary per shop | GraphQL webhookSubscriptionCreate |
| Mandatory compliance topics | shopify.app.toml (compliance_topics) |
| Legacy REST /admin/webhooks code | Migrate to one of the above |
Most apps end up hybrid: a declarative baseline for universal topics, plus GraphQL for anything merchant-configurable. If you're still scaffolding your project, our walkthrough of Shopify app development from first principles covers where webhook handlers fit in a Remix app template.
Verifying Deliveries with HMAC Signatures
Your webhook endpoint is a public URL that mutates your database. Anyone who discovers it can POST fake orders unless you verify every request — which is why Shopify signs each delivery and why verification is non-negotiable.
How the Signature Works
Shopify computes an HMAC-SHA256 digest of the raw request body using your app's client secret as the key, base64-encodes it, and sends it in the X-Shopify-Hmac-Sha256 header. You repeat the computation server-side and compare. Per Shopify's verification documentation, any delivery where the signatures don't match must be rejected.
A Working Node.js Verifier
Here's the pattern in Express, with the two details most implementations get wrong handled correctly:
const crypto = require("crypto");
const express = require("express");
const app = express();
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
// CRITICAL: capture the RAW body — express.json() would consume it first
app.post(
"/webhooks",
express.raw({ type: "application/json" }),
(req, res) => {
const hmacHeader = req.get("X-Shopify-Hmac-Sha256");
if (!hmacHeader) return res.sendStatus(401);
const digest = crypto
.createHmac("sha256", CLIENT_SECRET)
.update(req.body) // raw Buffer, NOT a parsed object
.digest("base64");
const valid = crypto.timingSafeEqual(
Buffer.from(digest, "base64"),
Buffer.from(hmacHeader, "base64")
);
if (!valid) return res.sendStatus(401);
// Acknowledge immediately; process async (see retry section)
res.sendStatus(200);
const topic = req.get("X-Shopify-Topic");
const payload = JSON.parse(req.body.toString("utf8"));
queueForProcessing(topic, payload, req.get("X-Shopify-Webhook-Id"));
}
);If you're using Shopify's official libraries (@shopify/shopify-api, or the Remix app template's authenticate.webhook(request)), verification is built in — but you should still understand what it's doing, because the failure mode of a misconfigured verifier is every webhook silently rejected.
The Two Classic Verification Bugs
- Parsed body instead of raw body. If
express.json()(or any framework body parser) runs first, you'll re-serialize the object and the bytes won't match — verification fails on every legitimate request. Mountexpress.raw()on the webhook route specifically. - String comparison instead of timing-safe comparison.
digest === headerleaks timing information an attacker can exploit. Usecrypto.timingSafeEqualon equal-length buffers, as above.
And one operational note: respond 401 to bad signatures, log them, and move on. Don't retry-process a request you couldn't authenticate.
At-Least-Once Delivery: Retries, Timeouts, and Ordering

Shopify webhooks are at-least-once, not exactly-once, and not ordered. Designing for those three words is most of webhook engineering.
The Delivery Contract
- Your endpoint must respond with a 200-range status. Anything else — including 3XX redirects — counts as a failure.
- Shopify enforces a 1-second connection timeout and a 5-second timeout for the entire request. Slow handlers are failed handlers.
- On failure, Shopify retries 8 times over the next 4 hours with increasing backoff.
- After repeated consecutive failures, Shopify emails your app's emergency developer address — and subscriptions created through the Admin API are automatically deleted. Declarative TOML subscriptions are restored on your next deploy, which is one more argument for them.
That 5-second ceiling drives the cardinal architecture rule: acknowledge first, process later. Verify the HMAC, persist or enqueue the payload, return 200, and do the real work — inventory sync, email triggers, order tagging and fulfillment automation — in a background worker. Hookdeck's getting-started guide to Shopify webhooks walks through the same queue-first pattern with worked examples.
Why Duplicates Happen
Suppose your handler finishes processing in 5.2 seconds. The work succeeded — but Shopify timed out at 5.0 and recorded a failure. Minutes later, the retry arrives and you process the same order twice. Network blips, deploys, and cold starts produce the same effect. Duplicates aren't an edge case; they're the steady-state behavior of any at-least-once system.
Ordering Isn't Guaranteed Either
Shopify does not guarantee event ordering within or across topics. An orders/updated can arrive before the orders/create it logically follows. Two defenses:
- Compare timestamps, don't assume sequence. Use
X-Shopify-Triggered-At(orupdated_atin the payload) and ignore events older than the state you've already stored. - Upsert, don't insert-then-update. If
orders/updatedarrives first, an upsert creates the record; the lateorders/createbecomes a no-op.
Building Idempotent Shopify Webhook Handlers
Idempotency means processing the same delivery twice produces the same result as processing it once. There are two complementary strategies — most production apps use both.
Strategy 1: Deduplicate on X-Shopify-Webhook-Id
Every delivery carries a unique X-Shopify-Webhook-Id. Persist it before processing:
async function handleDelivery(webhookId, topic, payload) {
const inserted = await db.query(
`INSERT INTO processed_webhooks (webhook_id)
VALUES ($1)
ON CONFLICT (webhook_id) DO NOTHING
RETURNING webhook_id`,
[webhookId]
);
if (inserted.rowCount === 0) return; // duplicate — already handled
await processEvent(topic, payload);
}The INSERT ... ON CONFLICT DO NOTHING makes the check-and-claim atomic, so two concurrent duplicate deliveries can't both pass. Prune rows older than a few days — retries stop after 4 hours, so a 7-day window is generous.
Strategy 2: Make the Operations Themselves Idempotent
ID-based dedupe protects against identical deliveries; it doesn't protect against two different events that touch the same data. Idempotent operations do:
- Upserts keyed on Shopify's resource ID (
order.id,product.id) instead of blind inserts - State checks before side effects — "send welcome email if not already sent," recorded in your DB, not in memory
- Absolute writes over relative ones — set
inventory = payload.available, neverinventory += delta
The Queue-First Reference Architecture
Put the pieces together and the shape is always the same:
- Endpoint verifies HMAC → 2. claims
X-Shopify-Webhook-Id→ 3. enqueues{topic, payload, webhookId}→ 4. returns 200 in milliseconds → 5. worker processes with idempotent operations and its own retry policy.
This keeps you under the 5-second timeout at any traffic level, survives worker crashes, and absorbs flash-sale bursts gracefully.
Scaling Up: Amazon EventBridge and Google Pub/Sub

Past a certain volume of Shopify webhooks, running your own HTTPS ingestion tier becomes the bottleneck: every flash sale is a self-inflicted DDoS against your own endpoint. Shopify supports two managed alternatives that remove your servers from the delivery path entirely.
Amazon EventBridge
Shopify acts as a partner event source for Amazon EventBridge: deliveries land on an event bus in your AWS account, and rules route them to Lambda, SQS, Step Functions, or anywhere else. Subscribe by setting the subscription uri to your EventBridge ARN. Shopify's delivery problem becomes AWS's scaling problem — which AWS is rather good at.
Google Pub/Sub
The GCP equivalent uses Google Cloud Pub/Sub as a message bus: grant Shopify's service account publish rights on a topic, then subscribe with uri = "pubsub://{project-id}:{topic-id}". Your consumers pull at their own pace with Pub/Sub's own ack/redelivery semantics, so a burst of 10,000 order events queues calmly instead of hammering an endpoint.
When to Switch
| Signal | Recommendation |
|---|---|
| Low volume, simple app | HTTPS is fine — don't add infrastructure |
| Timeouts during traffic spikes | Move hot topics (orders, inventory) to EventBridge/Pub/Sub |
| Already on AWS or GCP | Use the matching transport from day one |
| Multiple consumers per event | Message bus fan-out beats HTTPS fan-out |
Note that HMAC verification is an HTTPS concern; with EventBridge and Pub/Sub, authenticity comes from the cloud provider's IAM trust between Shopify and your account.
The Mandatory Compliance Webhooks
This section isn't optional reading: any app distributed through the Shopify App Store must respond to data subject requests, whether or not it collects personal data. App review checks for it.
The Three Topics
| Topic | Trigger | Your obligation |
|---|---|---|
| customers/data_request | A customer asks to view their stored data | Provide the requested data to the store owner |
| customers/redact | A store owner requests deletion of a customer's data | Erase that customer's personal data |
| shop/redact | Fires 48 hours after your app is uninstalled | Erase the shop's data |
Configuring Them
Compliance topics are declared in shopify.app.toml with the dedicated compliance_topics key:
[webhooks]
api_version = "2026-04"
[[webhooks.subscriptions]]
compliance_topics = [
"customers/data_request",
"customers/redact",
"shop/redact"
]
uri = "https://your-app.example.com/webhooks/compliance"The Response Rules Are Stricter Here
The response rules for these Shopify webhooks are stricter than for ordinary topics. Three requirements, verified during app review:
- Confirm receipt with a 200-series status code
- Return 401 Unauthorized when the HMAC header is invalid — for compliance topics this is an explicit requirement, not just best practice
- Complete the requested action within 30 days (unless you're legally required to retain the data)
Even an app that stores zero customer data must subscribe and return 200. Build the no-op handler; it's ten minutes of work that unblocks your App Store listing.
When Webhooks Go Missing: Failure Modes and Reconciliation

Here's the line from Shopify's own docs that should shape your architecture: webhook delivery isn't always guaranteed. Treat Shopify webhooks as an optimization for freshness, not as your source of truth.
The Failure Modes You'll Actually Hit
- Dropped deliveries — after 8 failed retries over 4 hours, that delivery is gone for good
- Silent subscription removal — repeated consecutive failures delete API-created subscriptions; if you miss the warning email, your app keeps running while hearing nothing
- Deploy-window outages — a bad release returns 500s for an hour; every event in that window exhausts retries against a broken endpoint
- The uninstall gap —
app/uninstalledcan fail like any other delivery, leaving you syncing against a shop that revoked your token weeks ago
Reconciliation: The Safety Net
A reconciliation job is a scheduled sweep that asks the Admin API what webhooks should have told you:
- Poll on a slow cadence — e.g., hourly, query
orders(query: "updated_at:>{last_sync}")and upsert anything your webhook path missed - Audit your subscriptions — periodically run the
webhookSubscriptionsquery and alert if expected topics are missing (this catches silent removal) - Heartbeat your topics — if a normally-chatty topic goes quiet for an abnormal stretch, page someone
Because your handlers are already idempotent, reconciliation reuses the exact same upsert path as live webhooks — re-processing overlap is harmless. Hookdeck's Shopify webhooks best-practices guide covers monitoring and alerting patterns for exactly these gaps.
Best Practices vs. Costly Mistakes
| Best practice | Costly mistake |
|---|---|
| Verify HMAC with raw body + timing-safe compare | Skipping verification "temporarily" in production |
| Return 200 fast, process via queue | Doing sync/email/API work inline before responding |
| Dedupe on X-Shopify-Webhook-Id + upsert by resource ID | Assuming each event arrives exactly once, in order |
| Declarative TOML subscriptions in version control | Hand-created subscriptions nobody remembers exist |
| Hourly reconciliation + subscription audits | Trusting webhooks as the sole source of truth |
| Implementing all three compliance topics before review | Discovering the requirement from a rejection email |
Testing Shopify Webhooks Locally
Shopify webhooks need a publicly reachable URL, and localhost:3000 isn't one. The current tooling makes this nearly frictionless.
shopify app dev Handles the Tunnel
Run shopify app dev and the CLI starts your app and provisions a temporary public tunnel (Cloudflare-backed) that forwards to your local server. Your TOML-declared subscriptions are pointed at the tunnel for the dev session, so real events from your development store — place a test order, edit a product — flow straight into your local handler with genuine payloads and valid signatures.
Triggering Sample Events on Demand
For fast iteration without clicking through the admin, the CLI ships a dedicated command:
shopify app webhook trigger \
--topic orders/create \
--api-version 2026-04 \
--address http://localhost:3000/webhooks \
--client-secret $SHOPIFY_CLIENT_SECRETIt sends a sample payload for the topic to any address — localhost HTTP, a remote HTTPS URL, a pubsub:// URI, or an EventBridge ARN. Pass --client-secret so the delivery is signed with a key you control and your HMAC verification runs for real instead of being stubbed out. Omit any flag and the CLI prompts interactively.
What to Test Before You Ship
Run this checklist against your handler — locally, then again on staging:
- Valid signature → 200; tampered body → 401
- The same delivery sent twice → exactly one side effect
orders/updatedarriving beforeorders/create→ correct final state- Handler responds in well under 5 seconds with a queue backlog
- All three compliance topics → 200 (and 401 on bad HMAC)
If you're stuck on a verification mismatch or a subscription that won't fire, the Talk Shop dev community on Discord is full of app developers who have debugged the exact same raw-body bug — it's usually a faster answer than a forum thread.
Ship Webhooks That Survive the Real World

Shopify webhooks are easy to start and unforgiving to run carelessly. The recipe that holds up in production:
- Subscribe declaratively in
shopify.app.toml; reach forwebhookSubscriptionCreateonly when configuration varies per shop - Verify every delivery — HMAC over the raw body, timing-safe comparison, 401 on mismatch
- Respect the 5-second budget — acknowledge fast, process in a queue
- Design for at-least-once — dedupe on
X-Shopify-Webhook-Id, upsert by resource ID, never trust ordering - Reconcile on a schedule — webhooks are a freshness optimization, the Admin API is the source of truth
- Ship the compliance trio before app review asks
Get those six right and webhooks become the most boring part of your stack — which is exactly what event infrastructure should be. For more deep dives like this one, browse the Shopify development category on our blog, and bring your webhook war stories to the Talk Shop Discord dev community — we'd genuinely like to hear them.
What's the nastiest webhook bug you've shipped — a duplicate-processing incident, a silent subscription removal, or something stranger?

About Talk Shop
The Talk Shop team — insights from our community of Shopify developers, merchants, and experts.
