Shopify signs every HTTPS webhook with an HMAC-SHA256 of the raw request body, keyed on your app’s client secret, and puts the base64 digest in the X-Shopify-Hmac-SHA256 header. Shopify webhook HMAC verification means hashing the unparsed body bytes with that same secret and comparing the two digests in constant time. Anything that fails the comparison gets a 401 and no further processing.
That is the easy half. The half that breaks in production is everything after the signature check: the five-second budget, the retries that arrive with stale payloads, and the duplicate deliveries that quietly create two fulfilments for one order.
How to verify a Shopify webhook HMAC in Node
The secret is your app’s client secret, the same value the Shopify CLI writes as SHOPIFY_API_SECRET. Not the access token, and not the storefront token.
import express from 'express';
import crypto from 'node:crypto';
const CLIENT_SECRET = process.env.SHOPIFY_API_SECRET!;
const app = express();
function verifyWebhook(rawBody: Buffer, header: string | undefined): boolean {
if (!header) return false;
const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(rawBody).digest();
const received = Buffer.from(header, 'base64');
// timingSafeEqual throws on a length mismatch, so guard it first.
return digest.length === received.length && crypto.timingSafeEqual(digest, received);
}
app.post('/webhooks', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyWebhook(req.body, req.get('x-shopify-hmac-sha256'))) {
res.status(401).end();
return;
}
const payload = JSON.parse(req.body.toString('utf8'));
const webhookId = req.get('x-shopify-webhook-id')!;
res.status(200).end();
void enqueue(webhookId, req.get('x-shopify-topic')!, payload);
});
The length guard matters. crypto.timingSafeEqual throws a RangeError: Input buffers must have the same byte length when the header is truncated or missing padding, and an unhandled throw inside a webhook route is a 500, which Shopify counts as a failed delivery.
On a Web-standard runtime such as a Next.js route handler or a Cloudflare Worker, read the body once with await request.text() and hash the string before you parse it. Reading request.json() first consumes the stream and you cannot get the original bytes back.
Shopify’s own verification guide shows the same shape. If you are on the React Router or Remix app template, authenticate.webhook(request) does the check for you and hands back { topic, shop, session, payload, webhookId }, where webhookId is documented as the idempotency key.
Why express.json() breaks the signature
A parsed and re-serialised body is not the same bytes. Key order can shift, non-ASCII characters may be escaped differently, and whitespace disappears. The digest of JSON.stringify(req.body) will not match the header, and you will spend an afternoon convinced your secret is wrong.
If you have app.use(express.json()) mounted globally, the webhook route needs its raw parser registered before that line, or on a separate router mounted ahead of it. Mount order, not route order, is what decides which parser runs.
The same trap catches body-parsing middleware in Fastify, Hono and Nest. Look for the framework’s raw-body escape hatch and use it on the webhook path only.
What Shopify expects back, and how quickly
A 200 series status code. Shopify’s HTTPS delivery docs are blunt about the budget: a one-second connection timeout and a five-second timeout for the whole request. Anything outside the 200 range counts as an error, including 3XX, so a redirect from a trailing-slash rule or an HTTP to HTTPS bounce will fail every delivery you receive.
Failures are retried eight times over four hours on an exponential backoff. That replaced the older nineteen-attempts-over-48-hours behaviour in a changelog entry dated 10 September 2024, so any tutorial still quoting 19 retries predates it. If the failures persist, Shopify removes the subscription and you get nothing until it is recreated.
That last point is an argument for declaring subscriptions in shopify.app.toml rather than calling webhookSubscriptionCreate per shop. App-specific subscriptions are reapplied for every install, so a removal heals on the next deploy. Shop-specific ones only come back if your own code notices they are gone.
The practical rule: acknowledge, then work. Verify the HMAC, write the delivery to a queue or a table, return 200, and let a worker do the API calls. Nothing that talks to a third party belongs inside those five seconds. On Next.js there is a lighter option for small handlers, covered in our piece on deferring work with after(), though a real queue still wins once retries matter.
How to stop processing the same webhook twice
Delivery is at-least-once at best. Shopify’s guidance says plainly that delivery is not always guaranteed and that ordering is not guaranteed within a topic, or across topics for the same resource. Two things follow.
Deduplicate on X-Shopify-Webhook-Id. Let the database decide, rather than a read-then-write that races itself:
create table webhook_deliveries (
webhook_id text primary key,
topic text not null,
received_at timestamptz not null default now()
);
const { rowCount } = await db.query(
`insert into webhook_deliveries (webhook_id, topic)
values ($1, $2) on conflict (webhook_id) do nothing`,
[webhookId, topic],
);
if (rowCount === 0) return; // already handled
Then guard against stale data. A retry carries the original payload, not a refreshed one, so a delivery that lands three hours late can describe a version of the product that no longer exists. Compare the X-Shopify-Triggered-At header, or the payload’s updated_at, against whatever you last wrote for that resource, and drop anything older.
Neither trick makes webhooks a source of truth. Run a reconciliation job that queries the Admin API with an updated_at filter on a schedule and repairs the drift, because a subscription that was silently removed for two days leaves a hole that no amount of idempotency will fill.
Cutting the payload down before it arrives
Subscriptions accept a filter expression using Shopify’s API search syntax, available since the 2024-07 API version. It suppresses deliveries that do not match, which is cheaper than receiving them and dropping them yourself.
[webhooks]
api_version = "2026-07"
[[webhooks.subscriptions]]
topics = ["products/update"]
uri = "https://app.example.com/webhooks/products"
filter = "variants.price:>=10.00"
include_fields = ["id", "title", "variants"]
Two rules that bite. Filters are case-sensitive, unlike the search syntax elsewhere in the Admin API. And every field referenced in the filter must also appear in include_fields, or there is nothing to evaluate against. The metaobjects/create, metaobjects/update and metaobjects/delete topics go further and require a filter = "type:{type}", which is worth knowing if you are driving content from custom data structures.
metafieldNamespaces works the same way for metafields you actually need on the payload.
When you can skip HMAC verification altogether
HMAC checks exist because an HTTPS endpoint is a public URL that anyone can POST to. Deliver to Amazon EventBridge or Google Cloud Pub/Sub instead and the channel itself is authenticated, so there is no signature to verify. Set the subscription uri to pubsub://{project-id}:{topic-id} or an EventBridge event-source ARN.
The gain is not just the missing crypto. The queue absorbs the five-second window, so a slow consumer stops costing you subscriptions. The cost is another piece of cloud infrastructure to own, which for a single-shop integration is rarely worth it, and for an app serving thousands of shops usually is.
One thing you cannot skip: the mandatory compliance webhooks. customers/data_request, customers/redact and shop/redact must be implemented, must complete within 30 days, and must return 401 Unauthorized when the HMAC header is invalid. App review tests exactly that.
Start with an HTTPS endpoint, express.raw on the webhook path, a timing-safe comparison, an immediate 200, and a unique index on the webhook id. That handles most integrations. Move to Pub/Sub or EventBridge when your processing genuinely cannot fit in five seconds, or when subscription removals start showing up in your logs, and declare subscriptions in shopify.app.toml from day one so a bad week does not cost you a topic permanently.
We build and maintain Shopify integrations for UK retailers, including the webhook plumbing that keeps an ERP or a warehouse system in step with the store. If you have handlers that miss events or double-process them, our Shopify development work usually starts by fixing that layer before anything else.