Plainwork

Plainwork / Webhook Spec Atlas / Shopify webhook HMAC validation failed: fixes

Shopify webhook HMAC validation failed: what to check

Shopify signs each HTTPS webhook delivery with a base64-encoded HMAC-SHA256 in the X-Shopify-Hmac-SHA256 header, computed over the raw request body with your app's client secret as the key. When validation fails, the cause is nearly always one of the five below. This page follows Shopify's own webhook documentation, read on 16 October 2026 (shopify.dev, HTTPS webhook delivery).

The five causes, most likely first

  1. A body parser ran first. Shopify's page warns that express.json() parses the body before your verification code runs, and says to place verification middleware before any body parsing. Use express.raw({type: "application/json"}) on the webhook route only.
  2. You compared hex to base64. The header is base64, not hex. Calling .digest("hex") always fails, and the two outputs look equally random.
  3. Wrong secret. The key is your app's client secret (the API secret key from the Partner Dashboard or app settings), not an access token and not a webhook ID. If you rotated the secret, Shopify says it can take up to an hour for the digest to use the new one, so a short burst of failures right after rotation is expected.
  4. Encoding of the body. Hash the bytes you received. Converting the buffer to a string and back can change non-ASCII characters such as accented names in customer data.
  5. Not an HTTPS delivery. Shopify states HMAC verification applies to HTTPS deliveries. Amazon EventBridge and Google Cloud Pub/Sub deliveries do not carry this header.

A minimal check in Node (no dependencies)

import crypto from "node:crypto";
import express from "express";

const app = express();
app.post("/webhooks/shopify", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-Shopify-Hmac-SHA256") || "";
  const digest = crypto.createHmac("sha256", process.env.CLIENT_SECRET)
    .update(req.body)            // Buffer: the raw bytes
    .digest();                   // compare as bytes
  const received = Buffer.from(header, "base64");
  const ok = received.length === digest.length && crypto.timingSafeEqual(received, digest);
  if (!ok) return res.status(401).end();
  // Retries and duplicates happen: de-duplicate on X-Shopify-Webhook-Id
  res.status(200).end();
});

Checking the length before timingSafeEqual matters: that function throws if the two buffers differ in length, so a malformed header would otherwise crash the handler instead of returning 401.

Once it verifies, answer fast

Shopify's documentation gives a 1 second connection timeout and 5 seconds for the whole request, and retries a failed delivery 8 times over the next 4 hours. After 8 consecutive failures an Admin API webhook subscription is deleted, so a handler that does slow work before replying can lose its subscription. Return 200 first, then process in a queue. A redirect (3xx) counts as an error.

Still stuck? Paste your secret, raw body and header into the free signature debugger. It runs in your browser and names the likely cause. The general mismatch checklist covers other senders.

Handling more than Shopify? The Webhook Spec Atlas ($9) lists the same facts (header, signed string, encoding, replay window, timeout, retries) for 16 senders, each read from the vendor's own docs, with tested verifiers.

More from the catalogue

Other small tools

See everything →