CanHook
Features Pricing Docs FAQ Blog Log in Get started free

Verifying Shopify Webhooks: Fixing the Raw-Body HMAC Bug

By The CanHook Team · September 4, 2026 · 1174 words
In short

Shopify webhook HMAC verification fails most often because a body-parsing step ran before your signature check, so you hash re-serialized JSON instead of Shopify's raw bytes. Read the raw body first, compute HMAC-SHA256 with your client secret, compare with hash_equals(), and only then parse the JSON.

Your Shopify app registers a webhook, Shopify sends a delivery, and your handler rejects it with a 401 even though you copied the client secret correctly. This is almost always one specific bug: something in your request pipeline parsed the body into an object before your signature check ran, so you are hashing re-serialized JSON instead of the exact bytes Shopify sent over the wire. Fix the order of operations and the same secret that did not work for two hours starts verifying every delivery.

Shopify webhook HMAC verification is the process of recomputing the HMAC-SHA256 digest of a webhook's raw request body using your app's client secret, then comparing that digest, byte for byte, against the value Shopify sent in the X-Shopify-Hmac-SHA256 header. Get the raw-body part wrong and no amount of double-checking the secret will fix it.

How does Shopify sign a webhook request?

Every webhook Shopify sends includes an X-Shopify-Hmac-SHA256 header: a base64-encoded HMAC-SHA256 digest of the raw request body, keyed with your app's client secret, the same secret shown in your Partner Dashboard app credentials (see Shopify's webhook verification docs for the full spec). Shopify computes this digest over the exact bytes it puts on the wire, before any framework, proxy, or language runtime touches them.

To verify a delivery, you recompute that same digest independently and compare it to the header. If the two values match, the request came from Shopify and was not altered in transit. If they do not match, you reject it, but a mismatch does not always mean an attacker; more often it means your own code hashed the wrong bytes.

Why does verification fail even with the right secret?

The failure that catches almost every team at least once: a body-parsing step, an automatic request-body binder, or an API gateway that normalizes payloads, runs before your HMAC check and replaces the raw body with a parsed, then re-serialized, version. JSON re-serialization is not guaranteed to reproduce the original bytes: key order, whitespace, and number formatting can all shift. HMAC-SHA256 is exact, so one changed byte anywhere in the body produces a completely different digest. A payload that looks identical once printed to a screen can still fail verification.

The fix is ordering, not cryptography: read and hash the raw body before anything else touches it, and only decode it to JSON after the HMAC check passes.

How do you verify a Shopify webhook in PHP?

The sequence that avoids the raw-body trap:

  1. Read the POST body from php://input before calling json_decode() or touching $_POST.
  2. Read the X-Shopify-Hmac-SHA256 request header.
  3. Compute HMAC-SHA256 of the raw body using your app's client secret, then base64-encode the result.
  4. Compare the computed value to the header using hash_equals(), never == or ===.
  5. Only after the comparison passes, decode the raw body to an array.
$rawBody = file_get_contents('php://input');
$hmacHeader = $_SERVER['HTTP_X_SHOPIFY_HMAC_SHA256'] ?? '';
$secret = getenv('SHOPIFY_CLIENT_SECRET');

$calculated = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));

if (!hash_equals($calculated, $hmacHeader)) {
    http_response_code(401);
    exit;
}

$payload = json_decode($rawBody, true);

hash_equals() matters as much as the raw body does. A plain == comparison short-circuits on the first mismatched byte, and the tiny timing difference between failing on byte 2 versus byte 30 is enough for an attacker to reconstruct a valid signature one byte at a time. hash_equals() always takes the same amount of time regardless of where the strings diverge.

What other bugs cause an HMAC mismatch?

Beyond parsing order, four other causes show up often enough to check systematically before you assume the secret itself is wrong:

SymptomRoot causeFix
Every webhook returns 401, including Shopify's own test deliveryBody-parsing step ran before the HMAC checkRead the raw body first; verify; decode JSON only after
Verification worked yesterday, fails todayClient secret rotated in the Partner Dashboard without updating your app configRe-pull the current secret and confirm which subscription it belongs to
Works for small payloads, fails on large ordersA proxy or CDN decompresses or re-encodes the body before your app sees itDisable body transformation on the ingress path for the webhook route
Passes locally, fails in productionA load balancer or reverse proxy appends a trailing newline or re-chunks the bodyLog the raw byte length in both environments and diff it

The second row is common enough to deserve its own note: rotating a signing secret without a coordinated cutover breaks every delivery until you update both sides. If a Shopify webhook secret in your stack ever needs scheduled rotation, the same zero-downtime approach used for rotating a webhook signing secret applies here: keep the old and new secret valid during a short overlap window instead of swapping instantly.

What happens if your handler is down when Shopify sends a webhook?

Shopify retries automatically. According to Shopify's webhook troubleshooting docs, a failed delivery, meaning a timeout or any non-2xx response, is retried up to 8 times over roughly 4 hours, with each attempt given a 5-second window before it counts as a timeout. Any 2xx status code counts as success and stops the retry sequence; Shopify does not inspect your response body.

That schedule is generous but not infinite. If your handler is down longer than the retry window, or a database write for that webhook fails silently, the event is gone and Shopify will not resend it later. Verify quickly and return a 2xx as soon as you have queued the work, not after you have finished processing it, to stay inside that window.

How do you confirm the fix actually works?

Once you have corrected the raw-body order, the tedious part is proving it stays fixed: every deploy, every new proxy, every reordered step is a chance to reintroduce the same bug, and a passing unit test with a hand-built payload does not prove your production pipeline still hashes the real bytes. Point the webhook subscription at a CanHook catch URL during development and staging, and it records the exact method, headers, and raw body of every delivery Shopify sends, so you can diff what Shopify actually sent against what your handler received instead of trusting a log statement placed in the wrong spot.

Isn't a request log enough?

You might reasonably ask why not just add a one-line logger to your existing handler instead of adopting another tool. A file log works until you need to replay that exact request against a fixed handler, compare a Shopify delivery side by side with a GitHub or Stripe delivery you are also debugging, or hand the raw payload to a teammate with no server access. A shared catch URL with free 24-hour retention costs nothing to try and saves the copy-paste each time.

If you are setting up a new Shopify webhook subscription, test it before your real integration depends on it: create a free CanHook endpoint, point the subscription at it, and send a test delivery. The raw headers and body appear immediately, so you can confirm your HMAC check reads the same bytes Shopify sent before you wire up a single line of business logic.

Frequently asked questions

What header does Shopify use for webhook signatures?

Shopify sends the signature in the X-Shopify-Hmac-SHA256 header as a base64-encoded HMAC-SHA256 digest of the raw request body, keyed with your app's client secret.

Why does my Shopify HMAC verification fail with the correct secret?

The most common cause is verifying against a body that a framework already parsed and re-serialized. HMAC over re-serialized JSON never matches HMAC over Shopify's original bytes, even when both look identical once printed.

How many times does Shopify retry a failed webhook?

Shopify retries a failed delivery up to 8 times over roughly 4 hours, giving each attempt a 5-second window and treating any 2xx response as success.

Does re-serializing a JSON body change its HMAC digest?

Yes. Key order, whitespace, and number formatting can all shift when a body is parsed and printed back out, and HMAC-SHA256 treats any single changed byte as a completely different input, so the digest no longer matches.

Can I use a plain string comparison instead of hash_equals?

No. A plain == comparison leaks timing information an attacker can use to guess the signature byte by byte. Use hash_equals() in PHP, or crypto.timingSafeEqual() in Node, so the comparison takes constant time.

What's the safest way to test a Shopify webhook handler?

Point the webhook subscription at a catch URL first, capture what Shopify actually sent, and replay the exact raw body against your local handler instead of guessing at a hand-built test payload.