ToolsFree tool
Shopify webhook HMAC verifier
Check an X-Shopify-Hmac-Sha256 header against your app's client secret and see why the HMAC doesn't match. Nothing you paste leaves your browser.
The result shows up here
Paste your app's client secret, the X-Shopify-Hmac-Sha256 header, and the raw body from a delivery, then verify. To see how it works first, use Try an example.
What gets signed
Base64(HMAC-SHA256(client secret, body))The body was parsed before it was verified
Shopify signs the raw body. If express.json() or another parser runs first, the body you hash is a re-serialized copy. Use express.raw({ type: "application/json" }) on the webhook route, or read the raw body before your framework parses it. Apps built from Shopify's templates can call authenticate.webhook(request), which verifies the HMAC for you.
The wrong secret
Webhooks an app subscribes to are signed with that app's client secret, shown next to its client ID in the app's settings. It isn't the API key, an Admin API access token (shpat_…), or the secret of another app.
The webhook was created in the store admin
Webhooks created under Settings → Notifications in the Shopify admin aren't tied to an app. Developers report that they're signed with the key shown on that page rather than an app's client secret.
The secret was just rotated
After you rotate the client secret, Shopify can take up to an hour to start signing with the new one. Accept both secrets until the change has taken effect.
The HMAC is compared as hex
The header is Base64, not hex. Encode your digest as Base64 before comparing, or decode the header to bytes and compare those with crypto.timingSafeEqual.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received (a Buffer), not re-serialized JSON.
export function verifyShopifyWebhook(rawBody, headers, clientSecret) {
const signature = Buffer.from(headers["x-shopify-hmac-sha256"] ?? "", "base64");
const expected = createHmac("sha256", clientSecret).update(rawBody).digest();
return signature.length === expected.length && timingSafeEqual(signature, expected);
}With Express, use express.raw({ type: "application/json" }) on the webhook route so rawBody is the original Buffer. In apps built from Shopify's templates, authenticate.webhook(request) does this check.
Shopify signs each HTTPS delivery and sends:
X-Shopify-Hmac-Sha256: the Base64-encoded HMAC-SHA256 of the raw body.X-Shopify-Webhook-Id: a unique ID for the delivery. Use it to skip duplicates.X-Shopify-TopicandX-Shopify-Shop-Domain: which event fired, and for which store.
To verify a request:
- Compute HMAC-SHA256 of the raw request body, keyed with the client secret's UTF-8 bytes.
- Base64-encode the result.
- Compare it in constant time with
X-Shopify-Hmac-Sha256.
The signature doesn't include a timestamp, so it doesn't expire. Deliveries to Google Cloud Pub/Sub or Amazon EventBridge aren't signed this way.
Sending webhooks from your own product? Cascadehook signs every delivery with Standard Webhooks, retries failures for up to 72 hours, and keeps every attempt for replay. Create a project or try the Standard Webhooks verifier.
Shopify is a trademark of Shopify Inc.. Cascadehook isn't affiliated with or endorsed by Shopify.