Shopify webhook verification, retries and deduplication
Shopify signs every webhook with HMAC-SHA256 over the raw request body, base64-encoded in the X-Shopify-Hmac-Sha256 header, keyed with your app's client secret. Your endpoint has five seconds to answer 200. A failed delivery is retried up to eight times over four hours, and a subscription that keeps failing inside a 24-hour period is removed. Deduplicate on X-Shopify-Webhook-Id and reconcile through the Admin API after any longer outage.
In short
- HMAC-SHA256 over the raw body, base64, in
X-Shopify-Hmac-Sha256; the key is the app client secret, or the admin page secret for merchant-created webhooks. - 1-second connect, 5-second total timeout, 200 required. Up to 8 retries over 4 hours; repeated failure inside 24 hours removes the subscription.
- Deduplicate on
X-Shopify-Webhook-Id; group onX-Shopify-Event-Id; order byX-Shopify-Triggered-At, never by arrival. - Verification: the provider’s own HMAC signature over the raw body.
- Deduplication:
X-Shopify-Webhook-Id. - Once verified, the event joins your catalogue with the same retries, log and replay as anything you publish yourself.
How do I verify a Shopify webhook HMAC in Node?
Shopify computes HMAC-SHA256 over the exact bytes of the request body and sends it
base64-encoded in X-Shopify-Hmac-Sha256. Compute the same digest with the secret
below, decode both to bytes, and compare in constant time. Verify before any JSON middleware
runs: a body that has been parsed and re-serialised is a different byte sequence, and the check
will fail on every request while looking correct in a unit test.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** Shopify: HMAC-SHA256 over the raw body, base64 in X-Shopify-Hmac-Sha256. */
export function verifyShopify(secret, header, rawBody) {
const expected = createHmac('sha256', secret).update(rawBody).digest('base64');
const a = Buffer.from(expected, 'base64');
const b = Buffer.from(String(header ?? ''), 'base64'); // compare the bytes, not the strings
return a.length === b.length && timingSafeEqual(a, b);
}
Which secret signs a Shopify webhook?
It depends on who created the subscription, and this is the most common reason a correct verification function returns false.
| Subscription created by | Signing key | Where to find it |
|---|---|---|
| Your app (app configuration, or the Admin API with the app's token) | The app's client secret (also called the API secret key) | Partner Dashboard → the app → Client credentials |
| A merchant, in the Shopify admin | The shared secret shown on that page | Settings → Notifications → Webhooks, at the bottom of the list |
After rotating the client secret, Shopify says it can take up to an hour before deliveries are signed with the new value. Verify against both secrets for that hour, or expect a burst of mismatches.
How does Shopify retry a failed webhook?
Shopify waits one second for the connection and five seconds for the whole request, and it
wants a 200. Anything else, including a slow 200, is a failed delivery.
A failed delivery is retried up to eight times over the next four hours. A subscription that
keeps failing inside a 24-hour period is removed, and a removed subscription receives nothing
until it is created again. Shopify states plainly that delivery is not guaranteed and that an
app should not rely on webhooks as its only source of truth.
| Behaviour | Shopify's figure |
|---|---|
| Connection timeout | 1 second |
| Total request timeout | 5 seconds |
| Success | 200 OK; any other status or a timeout counts as an error |
| Retries after a failure | Up to 8 attempts over 4 hours |
| Subscription removal | After multiple failures within a 24-hour period; removed subscriptions receive nothing until recreated |
| Ordering | Not guaranteed within a topic, nor across topics for the same resource |
| Delivery guarantee | None stated; reconcile through the Admin API after an outage |
| Client secret rotation | Up to 1 hour before deliveries use the new secret |
Four hours is short. A deploy that breaks the route on a Friday evening, or a database that is down for a morning, is enough to exhaust the retries for every order in that window. The handler below answers in milliseconds because it does nothing but verify and insert; the work happens afterwards, from the stored row.
import express from 'express';
import { verifyShopify } from './verify-shopify.mjs';
const app = express();
// Five seconds for the whole request, one of them for the connection. Nothing
// slower than a database insert belongs inside this function.
app.post('/webhooks/shopify', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyShopify(process.env.SHOPIFY_CLIENT_SECRET, req.get('x-shopify-hmac-sha256'), req.body)) {
return res.status(401).send('hmac mismatch');
}
// CREATE TABLE shopify_webhooks (
// webhook_id text PRIMARY KEY, event_id text, topic text NOT NULL, shop text NOT NULL,
// triggered_at timestamptz, payload jsonb NOT NULL, processed_at timestamptz);
const { rowCount } = await db.query(
`INSERT INTO shopify_webhooks (webhook_id, event_id, topic, shop, triggered_at, payload)
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (webhook_id) DO NOTHING`,
[
req.get('x-shopify-webhook-id'),
req.get('x-shopify-event-id'),
req.get('x-shopify-topic'),
req.get('x-shopify-shop-domain'),
req.get('x-shopify-triggered-at'),
JSON.parse(req.body),
],
);
res.status(200).end(); // a retry of the same delivery lands on the same primary key: rowCount 0
});
Which header do I deduplicate on: X-Shopify-Webhook-Id or X-Shopify-Event-Id?
Deduplicate on X-Shopify-Webhook-Id. Shopify documents it as the unique key of an
individual delivery, and a retry of that delivery repeats it. X-Shopify-Event-Id is
shared by every delivery the same merchant action produced, across topics and across
subscriptions, so it is the key for grouping, not for skipping: two deliveries with the same
event id and different topics are two different facts about one action.
| Header | What Shopify puts in it |
|---|---|
X-Shopify-Topic | The topic, for example orders/create |
X-Shopify-Hmac-Sha256 | Base64 HMAC-SHA256 of the raw body |
X-Shopify-Shop-Domain | The myshopify.com domain of the store |
X-Shopify-API-Version | The API version the payload was serialised with |
X-Shopify-Webhook-Id | Unique per delivery; the deduplication key |
X-Shopify-Event-Id | Shared by all deliveries from the same merchant action |
X-Shopify-Triggered-At | When Shopify triggered the delivery; use it, or the payload's updated_at, to order events |
X-Shopify-Name | Optional: the subscription name you set |
What do I do about the deliveries Shopify gave up on?
Fetch them. Shopify's own advice for an outage is to pull the data from the affected period through the Admin API and feed it into the same code path the webhook would have taken. Keep the handler's business logic behind a function that takes a payload and a topic, not a request, and reconciliation is a loop over an API page rather than a second implementation.
# After an outage longer than Shopify's four-hour retry window, the missed
# changes are not coming back on their own. Ask the Admin API for everything
# updated since the outage began and feed it through the same handler.
curl -s "https://$SHOP.myshopify.com/admin/api/2025-07/orders.json?status=any&updated_at_min=2026-09-07T02:00:00Z&limit=250" \
-H "X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN"
Receive Shopify webhooks through a verified HookGet source
Receiving Shopify webhooks through HookGet
Everything above is what the receiving end has to do by hand. A HookGet source does the verification and the deduplication before the event exists, and the event then gets the retry schedule, dead-letter queue and per-attempt log described on the rest of this site.
The contract
| Field | Value |
|---|---|
| Provider key | shopify |
| Header checked | X-Shopify-Hmac-Sha256: <base64> |
| Scheme | HMAC-SHA256 over the raw request body, base64-encoded, compared in constant time. |
| Deduplicated on | X-Shopify-Webhook-Id |
| Event type | X-Shopify-Topic becomes shopify.orders.create and similar. |
Setting it up
# 1. create a verified source; the secret is shown once
curl -X POST https://api.hookget.com/v1/sources \
-H "authorization: Bearer $HOOKGET_KEY" \
-H "content-type: application/json" \
-d '{"provider":"shopify","name":"Production"}'
# {"id":"src_…","secret":"whsec_…","ingest_path":"/ingest/src_…"}
- In the Shopify admin, open Settings → Notifications → Webhooks, or create the webhook through the Admin API.
- Paste the ingest URL as the destination and the source secret as the shared secret.
- Pick the topics you need; the topic header names the event on our side.
Worth knowing. Shopify computes its HMAC over the exact bytes it sent. Any middleware that reformats JSON before verification breaks the check — verify first, parse second.
What you get after verification
- The event appears in your log with a namespaced type, so Shopify traffic never collides with your own.
- It fans out to your destinations with the same retry schedule and dead-letter behaviour as any other event.
- The source secret rotates with an overlap window, so you can update Shopify at your own pace.
- Each inbound source is rate limited on its own, so a busy provider cannot exhaust your publish budget.
Questions
How many times does Shopify retry a failed webhook?
Up to eight times over four hours. Your endpoint must return 200 within five seconds, one of which is allowed for the connection. After multiple failures inside a 24-hour period the subscription is removed and nothing arrives until it is created again.
Why is my Shopify HMAC verification failing?
Most often the wrong key: app-created subscriptions are signed with the app's client secret, merchant-created ones with the secret on the admin's Notifications page, and a rotated secret takes up to an hour to apply. Next most often, the body was parsed before verification, or the base64 strings were compared instead of the decoded bytes.
Should I deduplicate on X-Shopify-Webhook-Id or X-Shopify-Event-Id?
On X-Shopify-Webhook-Id, which is unique per delivery and repeated by a
retry of that delivery. X-Shopify-Event-Id is shared by every delivery the same
merchant action produced across topics and subscriptions, so it groups related deliveries
rather than identifying duplicates.
Does Shopify deliver webhooks in order?
No. Shopify does not guarantee ordering within a topic or across topics for the same
resource. Order by X-Shopify-Triggered-At or the payload's
updated_at, and when a decision depends on current state, fetch the object from
the Admin API instead of trusting the last delivery to be the latest.
What happens to Shopify webhooks during an outage longer than four hours?
They are not redelivered. Shopify says delivery is not guaranteed and recommends fetching the data for the outage period from the Admin API and feeding it through your webhook processing code, which is why the business logic should take a payload rather than a request.
How do I verify a Shopify webhook?
HMAC-SHA256 over the raw request body, base64-encoded, compared in constant time. With HookGet you do not implement it: the source is created with a secret,
and every request is checked against X-Shopify-Hmac-Sha256: <base64> before the event exists.
What happens if Shopify sends the same event twice?
Redeliveries are deduplicated on X-Shopify-Webhook-Id, namespaced per source, so a repeat becomes
the same event rather than a second one.
What if verification fails?
The request is refused with 401 and nothing enters the pipeline. The rejection is logged on our side as a signal worth watching; the caller learns nothing beyond the status.
Start delivering webhooks today
Point your webhooks at HookGet and watch the first delivery arrive, signed, in under a minute.
Create a free account Try the free webhook tester
10,000 deliveries a month free, no credit card. The free tier blocks rather than bills, so trying it cannot produce an invoice.