Skip to content
ansezz.

▸ 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.

Computed digest

Supplied header

    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.

    webhook.js
    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.

    VerifyShopifyWebhook.php
    <?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

    Keep reading