Blog · · 5 min read
Shopify Webhooks: HMAC Verification, 5-Second Timeout, Retries
Shopify webhooks checked against the docs: base64 HMAC with the client secret, 5-second timeout, 8 retries over 4 hours, subscription removal, and Node.js code.
Key points
- Shopify retries a failed webhook 8 times over the following 4 hours. After 8 consecutive failures, subscriptions created through the Admin API are deleted automatically.
- Your endpoint has 1 second to accept the connection and 5 seconds to respond. Only 200-series responses count as success.
- X-Shopify-Hmac-Sha256 is a base64 HMAC-SHA256 of the raw body keyed with the app's client secret. Most verification failures come from a parsed body, comparing hex, or the wrong secret.
Shopify gives your webhook endpoint 1 second to accept the connection and 5 seconds to respond. If it doesn't get a 200-series response in time, it retries 8 times over the next 4 hours, and after 8 consecutive failures it deletes the subscription if it was created through the Admin API. Every delivery is signed in X-Shopify-Hmac-Sha256 with a base64 HMAC-SHA256 of the raw body, keyed with your app's client secret. This post collects the behavior in one table, checked against Shopify's docs on 2026-09-27, then covers the common HMAC mistakes and how to structure a handler that fits in 5 seconds.
Shopify webhook behavior at a glance
| Topic | Behavior | Source |
|---|---|---|
| Signature header | X-Shopify-Hmac-Sha256 (HTTPS deliveries only) |
Delivery structure |
| Algorithm | HMAC-SHA256, base64 | HTTPS delivery |
| Key | The app's client secret | Same |
| Signed content | The raw request body, before any parsing | Same |
| Connection timeout | 1 second | Same |
| Total timeout | 5 seconds | Same |
| Success | 200-series | Troubleshooting |
| Retries | 8 times over the next 4 hours | HTTPS delivery |
| Subscription removal | After 8 consecutive failures, if created through the Admin API | Same |
| Warning emails | Sent to the app's emergency developer email | Same |
| Duplicate key | X-Shopify-Webhook-Id (unique per delivery) |
Ignore duplicates |
| Ordering | Not guaranteed within a topic or across topics for the same resource | About webhooks |
| Delivery guarantee | Not always guaranteed; Shopify recommends reconciliation jobs | Same |
Headers on every delivery
| Header | Contents |
|---|---|
X-Shopify-Topic |
Topic name, for example products/update |
X-Shopify-Hmac-Sha256 |
Base64 HMAC signature |
X-Shopify-Shop-Domain |
The store's myshopify.com domain |
X-Shopify-API-Version |
API version used to serialize the payload |
X-Shopify-Webhook-Id |
Unique per delivery; use it to deduplicate |
X-Shopify-Triggered-At |
When Shopify triggered the delivery |
X-Shopify-Event-Id |
Shared by all deliveries from the same merchant action |
X-Shopify-Name (optional) |
The name you gave the subscription |
Shopify notes that HTTP/2 often lowercases header names, so treat them case-insensitively.
Three reasons the HMAC doesn't match
HMAC failures come up again and again on Shopify's developer forum (NestJS, another thread). Almost always it's one of these:
- The body was parsed first. Shopify's docs warn that middleware like
express.json()parses the body before your verification code runs. - Comparing hex. The header is base64. A
digest('hex')string won't match it. - Wrong secret. The key is the app's client secret, not a per-webhook value. Development and production apps have different client secrets.
Verifying in Node.js
Following Shopify's own example: compute a base64 HMAC over the raw body and compare in constant time.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET!;
// Raw body for this route only; register before express.json()
app.post('/webhooks/shopify', express.raw({ type: 'application/json' }), async (req, res) => {
const received = req.get('x-shopify-hmac-sha256') ?? '';
const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(req.body).digest('base64');
const a = Buffer.from(digest, 'base64');
const b = Buffer.from(received, 'base64');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
// Deduplicate on X-Shopify-Webhook-Id with a unique constraint
const webhookId = req.get('x-shopify-webhook-id')!;
const r = await db.query('INSERT INTO shopify_deliveries (id) VALUES ($1) ON CONFLICT DO NOTHING', [webhookId]);
if (r.rowCount === 0) return res.sendStatus(200);
await queue.add('shopify', {
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); // well inside 5 seconds
});
app.use(express.json());In NestJS, start the app with NestFactory.create(AppModule, { rawBody: true }) and read the unparsed Buffer from req.rawBody via RawBodyRequest (NestJS docs). Other frameworks are covered in getting the raw request body.
Fitting in 5 seconds
Five seconds is short compared with most providers; webhook timeouts compares them. Shopify's troubleshooting guide says to resolve timeouts by delaying processing until after you've sent the response. The handler should do four things only:
- Verify the HMAC.
- Drop duplicates by
X-Shopify-Webhook-Id. - Enqueue.
- Return 200.
Product syncs and external API calls belong in a worker. Shopify also recommends enabling Keep-Alive so connections are reused.
Planning for retries and subscription removal
Retries end 4 hours after the failure, and a subscription that fails 8 times in a row is gone. If your endpoint is down for more than about 4 hours, you lose those events and have to recreate the subscription.
- If a subscription was removed: fix the cause, recreate the subscription, and fetch current data through the Admin API to cover the gap. The missed events won't be resent.
- Reconcile regularly: Shopify says webhook delivery isn't always guaranteed and recommends reconciliation jobs that periodically fetch data from Shopify. A daily job catches gaps even when nothing looked wrong.
- Route the warning emails somewhere people read: failure warnings go to the app's emergency developer email. Use a team address.
- Check delivery status under your app's Overview and Logs in the Dev Dashboard.
Ordering
Shopify doesn't guarantee order and recommends sorting by the X-Shopify-Triggered-At header or the payload's updated_at. When updates for the same resource arrive out of order, ignore any update older than what you've stored:
UPDATE products
SET title = :title, updated_at = :updated_at
WHERE id = :id AND (updated_at IS NULL OR updated_at < :updated_at);For more on deduplication see handling duplicate webhooks, and for debugging signatures see why webhook signature verification fails.
When you're the one sending
If your own product sends webhooks to customers or apps the way Shopify does, you need retries, signatures, delivery logs and failure alerts. Choosing a retry window is covered in designing webhook retries.
Webhook Admin provides these through an API: Standard Webhooks signatures, 8 attempts over about 28 hours by default (you can choose the number of retries and the interval per endpoint) with a 15-second timeout per attempt, and a log of every attempt with the status code and the first 1,024 bytes of the response. It alerts you (Slack, Teams, email or a webhook) when deliveries keep failing, and disables an endpoint after five days of failures until you set it back to active. The free plan includes 50,000 messages a month: https://app.webhookadmin.com/signup
FAQ
Why does my Shopify HMAC check fail when I compare hex strings?
Shopify sends the HMAC-SHA256 base64-encoded. digest('hex') gives the same bytes written differently, so the strings never match. Compute with digest('base64'), or compare the raw bytes after base64-decoding the header.
My webhook subscription disappeared. How do I get it back?
Fix whatever was failing, then recreate the subscription, as Shopify's troubleshooting guide says. Events from while it was gone won't be redelivered, so fetch current data through the Admin API to catch up.
How do I detect duplicate Shopify webhooks?
Use X-Shopify-Webhook-Id. Shopify's guide says to look it up in persistent storage, skip processing and return success if it's there, and otherwise process the delivery and save the ID. X-Shopify-Event-Id is shared by every delivery from the same merchant action, so it isn't a duplicate key.