You register a Shopify webhook, trigger a test order, and your handler rejects it with a 401. The header is there, the secret looks right, and the digest still doesn't match. This post covers what Shopify actually signs, the mistakes that cause most mismatches, and verification code you can paste into plain Node, Express or a Remix / React Router app.
What Shopify signs
Every HTTPS webhook delivery includes an X-Shopify-Hmac-Sha256 header. Its value is:
base64( HMAC-SHA256( key = your app's client secret, message = raw request body ) )
That's it. There is no timestamp and no prefix. Your job is to recompute the same value from the bytes you received and compare it to the header. Every failure comes down to one of three inputs being different from what Shopify used: the bytes, the key, or the encoding.
Cause 1: you hashed parsed JSON instead of the raw body
This is the most common one by far. If express.json() (or any body parser) runs first, req.body is a JavaScript object. Calling JSON.stringify(req.body) doesn't give you back the original bytes: key order, whitespace, escaped slashes and Unicode escapes (\u00e9 vs é) can all change. One different byte gives you a completely different HMAC.
Fix: hash the raw Buffer that came off the wire, and make sure no JSON parser touches the webhook route before you verify it.
Cause 2: comparing hex to base64
Node's digest() defaults to returning a Buffer, and plenty of copy-pasted examples (often from Stripe or GitHub integrations) use .digest('hex'). Shopify's header is base64. A hex digest will never equal it, even when everything else is correct.
Fix: use .digest('base64'), or compare the decoded bytes, as shown below.
Cause 3: the wrong secret
The key is your app's client secret (sometimes labelled "API secret key" in the Partner Dashboard or Dev Dashboard). It's not:
- the client ID / API key,
- an Admin API access token (
shpat_...), - a secret from a different app or environment (dev app vs production app).
Two special cases:
- Webhooks you create by hand in the store admin under Settings > Notifications > Webhooks are signed with the key shown on that page, not an app secret.
- If you rotate the client secret, Shopify says it can take up to an hour before digests are generated with the new secret. During that window, a check against only the new secret can fail. If you rotate, try both secrets for a while.
Also watch for invisible problems in the environment variable itself: a trailing newline from echo "secret" > .env, surrounding quotes, or a value set in one deploy environment but not another. Logging secret.length (never the secret itself) is a quick sanity check.
Cause 4: something rewrote the body before your code saw it
Proxies, API gateways and some serverless adapters can decode, re-encode or decompress the body. If the code is correct and it still fails only in production, log req.body.length next to the request's Content-Length header. If they differ, something between Shopify and your handler is changing the payload.
Use a timing-safe compare (correctly)
A plain === won't cause a mismatch, but it can leak timing information, so use crypto.timingSafeEqual. One catch: it throws if the two buffers have different lengths. A missing or garbage header would crash the handler and return a 500 instead of a 401. Check the length first.
Plain Node helper
This helper works anywhere you have the raw body as a Buffer or string:
// verify-shopify.js
const crypto = require('node:crypto');
function verifyShopifyWebhook(rawBody, hmacHeader, secret) {
if (!hmacHeader || !secret) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // Buffer or string, exactly as received
.digest(); // raw 32 bytes
let received;
try {
received = Buffer.from(hmacHeader, 'base64');
} catch {
return false;
}
return (
received.length === expected.length &&
crypto.timingSafeEqual(received, expected)
);
}
module.exports = { verifyShopifyWebhook };
Comparing decoded bytes rather than base64 strings sidesteps any padding or whitespace differences in the header.
Express
Mount express.raw() on the webhook route before any global express.json(), or scope the JSON parser so it skips this path:
const express = require('express');
const { verifyShopifyWebhook } = require('./verify-shopify');
const app = express();
app.post(
'/webhooks/shopify',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifyShopifyWebhook(
req.body, // Buffer, thanks to express.raw
req.get('X-Shopify-Hmac-Sha256'),
process.env.SHOPIFY_CLIENT_SECRET
);
if (!ok) return res.sendStatus(401);
// Acknowledge first, work later (see the 5-second rule below)
res.sendStatus(200);
const payload = JSON.parse(req.body.toString('utf8'));
const topic = req.get('X-Shopify-Topic');
const webhookId = req.get('X-Shopify-Webhook-Id');
enqueue({ topic, webhookId, payload });
}
);
// Global JSON parsing for everything else goes AFTER the webhook route
app.use(express.json());
If req.body is {} or an object inside that handler, a parser ran first. Fix the middleware order before debugging anything else.
Remix / React Router (Shopify app template)
If you built your app from Shopify's Remix or React Router template, don't hand-roll verification. authenticate.webhook() reads the raw body, checks the HMAC and throws a 401 response if it fails:
// app/routes/webhooks.jsx
import { authenticate } from '../shopify.server';
export const action = async ({ request }) => {
const { topic, shop, payload } = await authenticate.webhook(request);
// payload is already parsed and verified
console.log(`Received ${topic} for ${shop}`);
return new Response();
};
The most common mistake here is reading the body before authenticate.webhook() runs, for example calling await request.json() for logging. A Request body can only be read once. Let the library read it first.
For a Remix route without the Shopify library, read the body as text yourself and verify that:
// app/routes/webhooks.shopify.jsx
import { verifyShopifyWebhook } from '~/lib/verify-shopify.server';
export const action = async ({ request }) => {
const rawBody = await request.text(); // read once, as-is
const ok = verifyShopifyWebhook(
rawBody,
request.headers.get('X-Shopify-Hmac-Sha256'),
process.env.SHOPIFY_CLIENT_SECRET
);
if (!ok) return new Response('Unauthorized', { status: 401 });
const payload = JSON.parse(rawBody);
// queue the work, then:
return new Response(null, { status: 200 });
};
request.text() decodes the body as UTF-8, and Shopify sends UTF-8 JSON, so hashing the string gives the same digest as hashing the bytes. If you want to be strict, use Buffer.from(await request.arrayBuffer()) instead.
The 5-second rule
Verification isn't the only reason deliveries "fail". Shopify allows one second to connect and five seconds for the whole request. Anything other than a 2xx in that window, including a 3xx redirect, counts as a failure. Shopify retries 8 times over about 4 hours. If the subscription was created through the Admin API, it gets deleted after 8 consecutive failures.
So:
- Verify the HMAC (fast, CPU only).
- Return
200right away. - Push the payload to a queue or background job for the slow work: API calls, database writes, emails.
- Deduplicate using
X-Shopify-Webhook-Id, because retries mean you can receive the same delivery twice.
Also make sure your endpoint doesn't redirect. A missing trailing slash or an HTTP-to-HTTPS redirect gives a 301, and Shopify counts that as a failure.
Debugging checklist
When the HMAC still doesn't match, go through these in order:
- [ ] Is
req.bodya Buffer or string, not an object? - [ ] Are you using
.digest('base64')(or comparing decoded bytes)? - [ ] Is the key the client secret of this app in this environment?
- [ ] Was the webhook created in the store admin? If so, use that page's signing key.
- [ ] Did you rotate the secret within the last hour?
- [ ] Does the env var have a stray newline or quotes?
- [ ] Does the body length match
Content-Length? - [ ] Do you return a 2xx within 5 seconds, without redirects?
Fixing the first three resolves the large majority of cases.
Disclosure: I build webhook tooling; there's a short reference version of this fix at Shopify HMAC verification failed. Launch offer: code LAUNCH40 takes 40% off my webhook guide (https://6907000850732.gumroad.com/l/xunaso) until Oct 11.
Top comments (0)