▸ Free tool
Shopify Webhook HMAC Verifier.
Paste the raw webhook body, the X-Shopify-Hmac-Sha256 header and your client secret. The digest is computed with the Web Crypto API on this page — your secret never leaves the browser.
▸ Raw bytes, or it won't match
HMAC signs the exact bytes Shopify sent. Pretty-printed, re-serialised or newline-padded JSON is a different message and will always fail — even when the data is identical.
The secret stays in this tab. It is read into crypto.subtle.importKey and never stored, never logged, never sent. Even so — pasting a production
secret into any web page is a call you should make deliberately. Prefer
a development app's secret, and rotate anything you are unsure about.
HMAC-SHA256 · base64 · recomputed as you type
Waiting for input
Awaiting input
Fill in all three fields. Nothing is computed until there is a secret to key the HMAC with.
— Supplied header
— ▸ Work through these, in this order
- 1 The body was parsed and re-serialised express.json(), await request.json(), json_encode($request->all()) — all of them hand you a new string. Key order, spacing, number formatting and unicode escaping can all shift. Hash the bytes you received, not a reconstruction of them.
- 2 Wrong secret Webhooks your app subscribes to are signed with the app's client secret (older dashboards call it the API secret key). Webhooks a merchant created by hand in Settings → Notifications are signed with that store's own webhook signing secret, printed on the same screen. They are different values.
- 3 A newline came along for the ride Copying the payload out of a log viewer or a terminal often appends a line break, and plenty of editors add one on save. One extra byte changes every bit of the digest.
- 4 Encoding drift The body was decoded to a string and re-encoded in a different charset, or CRLF line endings collapsed to LF somewhere in transit. HMAC sees bytes, so both are fatal.
- 5 Different app, different store The delivery came from a webhook subscription owned by another app — staging versus production is the classic — so you are verifying a real signature against a secret that never produced it.
- 6 Wrong signature scheme entirely App proxy requests are not webhooks. They are signed with a hex digest over sorted query parameters and arrive in a signature query parameter, not in X-Shopify-Hmac-Sha256.
How Shopify signs a webhook
Every webhook Shopify delivers carries an
X-Shopify-Hmac-Sha256 header. Shopify computes
HMAC-SHA256 over the exact bytes of the request body, keyed with your app's
client secret, and base64-encodes the 32-byte result. Because you hold the same
secret, you can recompute the digest and compare. A match proves two things
at once: the payload came from Shopify, and not one byte of it changed on the
way.
That matters more than it sounds. A webhook endpoint is a public URL that
accepts POSTs from anyone. Without verification, "order paid" is a claim
any stranger with curl can make about any order — an unauthenticated write
API wearing a webhook costume, happy to fulfil orders, grant entitlements
and trigger refunds for whoever guessed the URL. Verify first, parse
second, act third, and answer a bad signature with
401 so failures show up as failures in the
Partner dashboard instead of quietly succeeding.
This page does the same computation the Web Crypto way: the body goes
through TextEncoder to UTF-8 bytes, the
secret is imported with crypto.subtle.importKey as a non-extractable HMAC key, and crypto.subtle.sign produces the digest. That is the browser's native crypto — the same code path
that validates TLS — not a JavaScript reimplementation of SHA-256.
Raw body, or nothing works
The failure that eats an afternoon is always the same: something parsed
the body before you hashed it. JSON round-tripping is not byte-preserving.
Key order can move, whitespace disappears, "403.00" can come back as 403, and non-ASCII
characters may or may not survive as escape sequences. The data is
identical, the bytes are not, and HMAC only cares about bytes.
In Express the fix is ordering: mount express.raw() on the webhook route above the global express.json(), so the handler receives a Buffer instead of a parsed object.
import express from "express";
import crypto from "node:crypto";
const app = express();
// Raw parser FIRST, scoped to the webhook route. The express.json() below
// never sees this request, so req.body stays a Buffer of the exact bytes.
app.post(
"/webhooks/shopify",
express.raw({ type: "application/json" }),
(req, res) => {
const sent = req.get("X-Shopify-Hmac-Sha256") ?? "";
const digest = crypto
.createHmac("sha256", process.env.SHOPIFY_API_SECRET)
.update(req.body)
.digest("base64");
const a = Buffer.from(digest, "utf8");
const b = Buffer.from(sent, "utf8");
// timingSafeEqual throws on a length mismatch, so check length first.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
res.sendStatus(200); // Acknowledge inside 5s, then work off-request.
void enqueue(req.get("X-Shopify-Webhook-Id"), req.body);
},
);
app.use(express.json()); // Everything else, mounted after the webhook route.
Remix and React Router have a subtler version of the same trap: a request
body is a stream you can read exactly once. Call await request.text(), verify that string, then parse it yourself — call request.json() first and there is nothing left to hash. Shopify's own Remix template hides
all of this behind authenticate.webhook(request), which is the right thing to use when you have it.
Laravel is friendlier — $request->getContent() returns the untouched body — but two things still bite. Reaching for json_encode($request->all()) because it "looks the same" produces a different string, and forgetting to
exempt the route from CSRF verification gets you a 419 before your middleware
ever runs.
<?php
// app/Http/Middleware/VerifyShopifyWebhook.php
public function handle(Request $request, Closure $next): Response
{
$sent = (string) $request->header('X-Shopify-Hmac-Sha256', '');
// getContent() is the untouched body.
// json_encode($request->all()) is a different string. Do not use it.
$digest = base64_encode(hash_hmac(
'sha256',
$request->getContent(),
config('services.shopify.secret'),
true,
));
// hash_equals is the constant-time comparison. === is not.
abort_unless(hash_equals($digest, $sent), 401);
return $next($request);
}Why the comparison is a loop
Comparing the two digests with === or strcmp is a real, if narrow, vulnerability. String equality bails out the moment
it finds a differing byte, so a guess that shares the first ten characters takes
measurably longer to reject than one that differs immediately. With enough requests
to average out network noise, that leak lets an attacker rebuild the correct
digest one character at a time — on the order of 44 × 64 attempts instead of
2^256.
The fix is boring: walk the full length of both values, XOR each pair of
bytes, OR the result into an accumulator, and only look at that
accumulator at the very end. Fold the length difference into the
accumulator too, so a truncated value cannot exit early either. The
comparison then costs the same whether the first byte differs or none of
them do. That is what
hash_equals() does in PHP,
crypto.timingSafeEqual() in Node, and
hmac.compare_digest() in Python. This page
runs the same loop — and then, separately, tells you where the two digests first
diverge. That readout would be inexcusable on a live endpoint; here you are
the only person on both ends of the timer, and finding the difference is the
entire point.
A valid signature is not a safe request
HMAC proves authenticity and integrity. It says nothing about freshness. A valid webhook captured off the wire — or replayed out of your own logs — verifies perfectly the second time, and the hundredth. Shopify delivers at-least-once and retries failures over roughly 48 hours, so duplicates are not a hypothetical attack scenario. They are Tuesday.
Two habits close the gap. Treat
X-Shopify-Webhook-Id as an idempotency key:
store it behind a unique index, and if the insert conflicts, return 200 and
do nothing else — retries of the same delivery reuse that id. And reject stale
payloads by checking
X-Shopify-Triggered-At against a window
you pick, a few minutes wide, which caps how long a captured request stays useful
to anyone who copied it. Budget your response while you are there: Shopify wants
an acknowledgement within five seconds, so verify, enqueue, reply — never do
the work on the request thread.
One scope note. This page verifies webhook deliveries. App proxy requests
are signed differently — sorted query parameters, a hex digest, a
signature query parameter instead of a header
— so a perfectly valid proxy request will never verify here, and that is correct
behaviour rather than a bug in either of us.
Questions, answered.
Why does my Shopify webhook HMAC verification always fail?
Nine times out of ten the body was parsed before you hashed it. A JSON parser plus a serialiser is not an identity function — key order, whitespace, number formatting and unicode escaping can all change — so the reconstructed string hashes to something else. Capture the raw bytes before any body parser runs and hash those. The remaining failures are usually the wrong secret or a stray trailing newline picked up while copying the payload.
Which secret does Shopify use to sign webhooks?
It depends on who created the subscription. Webhooks your app registers through the Admin API or its app config are signed with the app's client secret, which older Partner dashboards label the API secret key. Webhooks a merchant creates by hand in Settings → Notifications are signed with that store's own webhook signing secret, shown on the same admin screen. Using one where the other applies produces a perfectly valid digest that never matches.
Do I have to use the raw request body to verify a Shopify webhook?
Yes, and it is the hardest part in most frameworks. In Express, mount express.raw() on the webhook route above the global express.json() so the handler receives a Buffer. In Remix or React Router, call await request.text() and verify that string before parsing it, because the body stream can only be read once. In Laravel use $request->getContent(), never json_encode($request->all()), and exclude the route from CSRF verification.
Is it safe to paste my Shopify client secret into this tool?
The page never transmits it: the secret goes into a password field, is read straight into crypto.subtle.importKey, and is never stored, logged or sent anywhere — you can watch the network tab stay empty. That said, pasting a production secret into any web page is a judgement call, and browser extensions can read the DOM of pages you visit. Use a development app's secret where you can, and rotate anything you are unsure about.
How do I stop a Shopify webhook being processed twice?
Assume every webhook will arrive more than once. Shopify retries a failed or slow delivery repeatedly over roughly 48 hours, and at-least-once delivery means duplicates happen even when nothing is broken. Use the X-Shopify-Webhook-Id header as an idempotency key: store it with a unique index, and if the insert conflicts, acknowledge with 200 and do nothing else. Verifying the signature proves the message is authentic, not that it is new.
Does this tool work for Shopify app proxy signatures?
No — app proxy requests use a different scheme. Shopify sorts the query parameters, concatenates them, computes HMAC-SHA256 with the same client secret, and sends a lowercase hex digest in the signature query parameter rather than a base64 digest in a header. This page verifies webhook deliveries only. Mandatory compliance webhooks such as customers/data_request use the standard webhook scheme, so those do work here.
Keep going
Article
Agentic commerce on Shopify
Where webhooks sit when an agent, not a human, is driving the checkout — and why authenticity checks stop being optional.
Article
Shopify UCP quick start
Getting a Shopify app talking to the commerce protocol, signed requests and all.
Tool
SHA Hash Generator
The unkeyed version of what happens here — SHA-1 through SHA-512 for text or a file, in hex or base64.
Tool
Base64 Encoder / Decoder
For when the digest is the easy part and the payload encoding is what's actually broken.
Keep reading
-
▸ Tool
Shopify GID Decoder
Once the webhook verifies, decode the resource IDs inside it.
-
▸ Tool
SHA Hash Generator
The primitive underneath: SHA-256 over the raw request body.
-
▸ Post
/blog/secure-agentic-commerce-shopify/
Signature verification is the floor, not the ceiling, for agent traffic.
-
▸ Post
/blog/synchronous-vs-asynchronous-communication/
Verify fast, queue the work — why webhook handlers must return in milliseconds.