Diagnostic Orders, payments, and webhooks

Webhook HMAC check keeps failing

Shopify is sending the webhook. Your endpoint is receiving it. And yet every single request fails your HMAC check, so you reject events you should be accepting. Almost always the cause is simple and invisible: something read the raw body, turned it into a JSON object, and your verification code hashed that object instead of the bytes Shopify actually signed. Here is why that happens and a small, tested check that verifies against the raw bytes every time.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A room of network wires
Photo by Ivan N on Unsplash
The short answer

Shopify signs the exact bytes of the request body and sends the signature in the X-Shopify-Hmac-Sha256 header. If your framework or your own code parses that body into a JSON object before your verification step runs, and you then hash JSON.stringify(parsedBody) or json.dumps(parsed) instead of the original bytes, the hash never matches, no matter how correct your secret is. The fix is to capture the raw body first, verify it, and only parse it afterward. Below is a pure, tested function that does the comparison, plus a small audit job that also catches webhook subscriptions delivering as XML instead of JSON, the other common way this same symptom shows up.

The problem in plain words

Every Shopify webhook arrives with a body and a header called X-Shopify-Hmac-Sha256. That header is a signature: Shopify takes the raw bytes of the request, runs HMAC-SHA256 with your app's secret, and encodes the result as base64. To trust the webhook, you do the same thing on your side and compare the two values.

The part that trips almost everyone up is the word "raw." Most web frameworks are built to make your life easier by parsing JSON automatically, so by the time your route handler runs, req.body is already a plain object, not the bytes that arrived on the wire. If your HMAC check re-serializes that object to compute a hash, it is comparing a signature for one set of bytes against a hash of a different, re-created set of bytes. Key order can shift, whitespace can change, a number can get reformatted, and a unicode character can get re-escaped differently. Any one of those is enough to make the hash disagree, and it disagrees on one hundred percent of requests, which is exactly the confusing part: it looks like every webhook is broken, so people assume the secret is wrong, when the secret was never the problem.

Shopify signs the raw bytes sent Body parser turns it into JSON bytes are gone Hash the object re-serialized JSON Mismatch webhook rejected
By the time the check runs, the framework has already replaced the signed bytes with a JSON object. Hashing that object can never reproduce Shopify's signature.

Why it happens

The HMAC never breaks because of the math. It breaks because something between the network socket and your verification code changes the bytes. A few common ways teams end up here:

This is a very common source of confusion because the failure is silent and total. Nothing throws an obvious error, the webhook simply gets a 401 or your handler quietly drops it, and it fails for every topic and every order, which makes it look like a Shopify-side outage rather than a one-line ordering bug in your own stack. See the citations at the end for the exact docs this is built from.

The key insight

Verification has to happen before parsing, on the literal bytes, or it cannot work at all. The fix is not a smarter comparison, it is capturing the raw request body first, checking it, and only then handing a parsed copy to the rest of your code. Because the check itself takes plain bytes and a header value and returns true or false, it needs no framework and no network to test, so we can prove it is correct once and never worry about it again.

The fix, as a flow

We do not touch how Shopify signs anything, that side is fixed. We change our own handler so the raw body is captured before any JSON parsing runs, verify that exact byte string against the header with a constant time comparison, and only parse the body into an object after verification passes. A companion audit job also checks the Admin GraphQL API for any webhook subscription still delivering as XML, which produces the same "it never verifies" symptom for a different reason, and switches it back to JSON.

Request arrives raw bytes on the wire Capture raw body before any JSON parsing Compute HMAC on the raw bytes Matches the header? yes no, reject 401 Parse and process now safe to trust
Capture first, verify second, parse last. Reversing that order is what breaks the check.

Build it step by step

1

Get the app's client secret and an Admin API access token

Your app's client secret is what Shopify used to sign the webhook, find it in the Partner Dashboard under your app's API credentials, or in your custom app's settings under Settings, Apps and sales channels, Develop apps. For the companion audit that checks webhookSubscriptions, install a custom app with the read_webhooks and write_webhooks scopes to get an Admin API access token that starts with shpat_. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export SHOPIFY_CLIENT_SECRET="shpss_..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch and node:crypto built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export SHOPIFY_CLIENT_SECRET="shpss_..."
export DRY_RUN="true"   // start safe, change to false to write
2

Capture the raw body before anything parses it

This is the one change that matters most. Read the request as bytes, or configure your framework to hand you the raw buffer for this route, before any JSON body parser runs. If you cannot remove a global parser, give it a verify callback that stashes the raw bytes on the request so your handler can still reach them.

step2.py (Flask example)
from flask import request

@app.route("/webhooks/orders-paid", methods=["POST"])
def orders_paid():
    raw_body = request.get_data()  # bytes, read before request.get_json() is ever called
    header_hmac = request.headers.get("X-Shopify-Hmac-Sha256", "")
    if not verify_hmac(raw_body, header_hmac, CLIENT_SECRET):
        return "unauthorized", 401
    payload = request.get_json()  # safe to parse only after verification passes
    ...
step2.js (Express example)
import express from "express";

const app = express();

// express.raw keeps req.body as a Buffer for this route, no JSON parsing happens here
app.post("/webhooks/orders-paid", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body; // Buffer, the exact bytes Shopify sent
  const headerHmac = req.get("X-Shopify-Hmac-Sha256") || "";
  if (!verifyHmac(rawBody, headerHmac, CLIENT_SECRET)) {
    return res.status(401).send("unauthorized");
  }
  const payload = JSON.parse(rawBody.toString("utf8")); // safe to parse only after verification passes
  ...
});
3

Compute the HMAC, with one pure function

Keep the digest calculation in its own function that takes raw bytes and a secret and returns a base64 string. It touches nothing outside its arguments, so it is trivial to test and impossible to get wrong by accident from framework quirks.

compute.py
import base64
import hashlib
import hmac

def compute_hmac(raw_body, secret):
    if isinstance(raw_body, str):
        raw_body = raw_body.encode("utf-8")
    if isinstance(secret, str):
        secret = secret.encode("utf-8")
    digest = hmac.new(secret, raw_body, hashlib.sha256).digest()
    return base64.b64encode(digest).decode("utf-8")
compute.js
import crypto from "node:crypto";

export function computeHmac(rawBody, secret) {
  const bodyBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8");
  return crypto.createHmac("sha256", secret).update(bodyBuffer).digest("base64");
}
4

Compare in constant time, with a pure decision function

The comparison itself should never short-circuit on the first differing byte, or an attacker could time their way to a valid signature. Use your language's constant time comparison, and treat a missing header as an automatic fail. This function takes only the raw body, the header value, and the secret, and returns true or false, so it needs no request object, no database, and no network to test.

decide.py
def verify_hmac(raw_body, header_hmac, secret):
    if not header_hmac:
        return False
    expected = compute_hmac(raw_body, secret)
    return hmac.compare_digest(expected, header_hmac)
decide.js
export function verifyHmac(rawBody, headerHmac, secret) {
  if (!headerHmac) return false;
  const expected = computeHmac(rawBody, secret);
  const expectedBuf = Buffer.from(expected, "utf8");
  const givenBuf = Buffer.from(headerHmac, "utf8");
  if (expectedBuf.length !== givenBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, givenBuf);
}
5

Audit for the other cause: a subscription still delivering as XML

If the raw body is already correct and the check still fails, list your webhook subscriptions with the Admin GraphQL API and check the format field. A subscription registered with XML sends a different byte layout than a JSON-oriented check expects, which produces the exact same symptom. Page through with a cursor so the audit covers every subscription, not just the first page.

step5.py
SUBSCRIPTIONS_QUERY = """
query($cursor: String) {
  webhookSubscriptions(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes { id topic uri format }
  }
}"""

def all_subscriptions():
    cursor = None
    while True:
        data = gql(SUBSCRIPTIONS_QUERY, {"cursor": cursor})["webhookSubscriptions"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]

def misconfigured_subscriptions(subscriptions):
    return [s for s in subscriptions if (s.get("format") or "").upper() != "JSON"]
step5.js
const SUBSCRIPTIONS_QUERY = `
query($cursor: String) {
  webhookSubscriptions(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes { id topic uri format }
  }
}`;

async function* allSubscriptions() {
  let cursor = null;
  while (true) {
    const data = (await gql(SUBSCRIPTIONS_QUERY, { cursor })).webhookSubscriptions;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

export function misconfiguredSubscriptions(subscriptions) {
  return subscriptions.filter((s) => (s.format || "").toUpperCase() !== "JSON");
}
6

Repair the format and wire it together with a dry run guard

When a subscription is flagged, webhookSubscriptionUpdate switches its format to JSON without recreating it or interrupting delivery. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the audit only reports which subscriptions it would switch. Read the output, agree with it, then switch it off to let it write. Run it on a schedule, and run the raw body check on every request, always.

Run it safe

Always start with DRY_RUN=true for the audit, and never log the raw body or the secret anywhere, since both are sensitive. The verification function itself performs no writes, it only decides accept or reject, so it is safe to run on every request without a flag.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, exposes the pure verification function for your own webhook route to import, logs what the audit finds, respects the dry run flag, and is safe to run again and again because it only touches subscriptions that are not already JSON.

View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.

verify_webhook_hmac.py
"""Verify Shopify webhook HMAC signatures against the raw request body, not the parsed one.

The Admin webhook HMAC is computed over the exact bytes Shopify sent. If a framework,
a logging middleware, or your own handler parses the JSON body first and later
recomputes the signature from `json.dumps(parsed_body)`, the bytes drift, so every
single webhook fails verification even though nothing is actually wrong. This module
holds the pure verification function (no I/O, safe to unit test) plus a small audit
job that pages through webhookSubscriptions on the Admin GraphQL API and flags any
subscription whose delivery format is not JSON, since that is the other common cause
of "the signature never matches." Run the audit on a schedule. Safe to run again and
again, it only reads and, when DRY_RUN is off, switches the format back to JSON.
"""
import base64
import hashlib
import hmac
import logging
import os

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("verify_webhook_hmac")

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
CLIENT_SECRET = os.environ.get("SHOPIFY_CLIENT_SECRET", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

SUBSCRIPTIONS_QUERY = """
query($cursor: String) {
  webhookSubscriptions(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes { id topic uri format }
  }
}"""

UPDATE_MUTATION = """
mutation($id: ID!, $uri: String!) {
  webhookSubscriptionUpdate(id: $id, webhookSubscription: { uri: $uri, format: JSON }) {
    webhookSubscription { id topic uri format }
    userErrors { field message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def compute_hmac(raw_body, secret):
    """Pure. Returns the base64-encoded HMAC-SHA256 of the raw request bytes.

    raw_body must be the exact bytes Shopify posted, before any JSON parsing.
    secret is the app's client secret, as a str or bytes.
    """
    if isinstance(raw_body, str):
        raw_body = raw_body.encode("utf-8")
    if isinstance(secret, str):
        secret = secret.encode("utf-8")
    digest = hmac.new(secret, raw_body, hashlib.sha256).digest()
    return base64.b64encode(digest).decode("utf-8")


def verify_hmac(raw_body, header_hmac, secret):
    """Pure decision function. No I/O, no dependency on Flask/Django/Express objects.

    raw_body: the exact bytes (or str) of the request body, read before parsing.
    header_hmac: the value of the X-Shopify-Hmac-Sha256 header, base64 text.
    secret: the app's client secret.
    Returns True only when the computed digest matches the header byte for byte,
    compared in constant time so timing does not leak information about the secret.
    """
    if not header_hmac:
        return False
    expected = compute_hmac(raw_body, secret)
    return hmac.compare_digest(expected, header_hmac)


def misconfigured_subscriptions(subscriptions):
    """Pure decision function. No I/O.

    subscriptions: list of dicts with at least "format", as returned by Shopify.
    A webhook registered with format XML changes the byte layout of the body but
    not how most starter code recomputes the signature, which is a second, unrelated
    way teams end up chasing a false HMAC mismatch. Returns the subset that is not
    already using JSON.
    """
    return [s for s in subscriptions if (s.get("format") or "").upper() != "JSON"]


def all_subscriptions():
    cursor = None
    while True:
        data = gql(SUBSCRIPTIONS_QUERY, {"cursor": cursor})["webhookSubscriptions"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def switch_to_json(subscription_id, uri):
    result = gql(UPDATE_MUTATION, {"id": subscription_id, "uri": uri})["webhookSubscriptionUpdate"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["webhookSubscription"]["format"]


def run():
    subs = list(all_subscriptions())
    bad = misconfigured_subscriptions(subs)

    for sub in bad:
        log.warning(
            "Webhook %s at %s uses format %s, not JSON. %s",
            sub["topic"], sub["uri"], sub.get("format"),
            "would switch to JSON" if DRY_RUN else "switching to JSON",
        )
        if not DRY_RUN:
            switch_to_json(sub["id"], sub["uri"])

    log.info(
        "Done. %d of %d webhook subscription(s) %s.",
        len(bad), len(subs), "to fix" if DRY_RUN else "switched to JSON",
    )


if __name__ == "__main__":
    run()
verify-webhook-hmac.js
/**
 * Verify Shopify webhook HMAC signatures against the raw request body, not the parsed one.
 *
 * The Admin webhook HMAC is computed over the exact bytes Shopify sent. If a framework,
 * a logging middleware, or your own handler parses the JSON body first and later
 * recomputes the signature from JSON.stringify(parsedBody), the bytes drift, so every
 * single webhook fails verification even though nothing is actually wrong. This module
 * exports the pure verification function (no I/O, safe to unit test) plus a small audit
 * job that pages through webhookSubscriptions on the Admin GraphQL API and flags any
 * subscription whose delivery format is not JSON, since that is the other common cause
 * of "the signature never matches." Run the audit on a schedule. Safe to run again and
 * again, it only reads and, when DRY_RUN is off, switches the format back to JSON.
 */
import { pathToFileURL } from "node:url";
import crypto from "node:crypto";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function computeHmac(rawBody, secret) {
  const bodyBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8");
  return crypto.createHmac("sha256", secret).update(bodyBuffer).digest("base64");
}

export function verifyHmac(rawBody, headerHmac, secret) {
  if (!headerHmac) return false;
  const expected = computeHmac(rawBody, secret);
  const expectedBuf = Buffer.from(expected, "utf8");
  const givenBuf = Buffer.from(headerHmac, "utf8");
  if (expectedBuf.length !== givenBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, givenBuf);
}

export function misconfiguredSubscriptions(subscriptions) {
  return subscriptions.filter((s) => (s.format || "").toUpperCase() !== "JSON");
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const SUBSCRIPTIONS_QUERY = `
query($cursor: String) {
  webhookSubscriptions(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes { id topic uri format }
  }
}`;

const UPDATE_MUTATION = `
mutation($id: ID!, $uri: String!) {
  webhookSubscriptionUpdate(id: $id, webhookSubscription: { uri: $uri, format: JSON }) {
    webhookSubscription { id topic uri format }
    userErrors { field message }
  }
}`;

async function* allSubscriptions() {
  let cursor = null;
  while (true) {
    const data = (await gql(SUBSCRIPTIONS_QUERY, { cursor })).webhookSubscriptions;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function switchToJson(subscriptionId, uri) {
  const result = (await gql(UPDATE_MUTATION, { id: subscriptionId, uri })).webhookSubscriptionUpdate;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.webhookSubscription.format;
}

export async function run() {
  const subs = [];
  for await (const sub of allSubscriptions()) subs.push(sub);
  const bad = misconfiguredSubscriptions(subs);

  for (const sub of bad) {
    console.warn(
      `Webhook ${sub.topic} at ${sub.uri} uses format ${sub.format}, not JSON. ${DRY_RUN ? "would switch to JSON" : "switching to JSON"}`
    );
    if (!DRY_RUN) await switchToJson(sub.id, sub.uri);
  }

  console.log(`Done. ${bad.length} of ${subs.length} webhook subscription(s) ${DRY_RUN ? "to fix" : "switched to JSON"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The verification rule is the part most worth testing, because it decides whether a real webhook is trusted. Because we kept verify_hmac pure, taking only bytes, a header string, and a secret, the test needs no network, no Shopify account, and no running web server. It just signs a body by hand and checks the answer, including the exact re-serialization mistake that causes the bug in the first place.

test_webhook_hmac_verify.py
import base64
import hashlib
import hmac as hmac_lib

from verify_webhook_hmac import compute_hmac, verify_hmac, misconfigured_subscriptions

SECRET = "shpss_test_secret"


def sign(raw_body, secret=SECRET):
    digest = hmac_lib.new(secret.encode("utf-8"), raw_body.encode("utf-8"), hashlib.sha256).digest()
    return base64.b64encode(digest).decode("utf-8")


def test_verify_true_for_correct_raw_body():
    raw_body = '{"id":1,"note":null}'
    header = sign(raw_body)
    assert verify_hmac(raw_body, header, SECRET) is True


def test_verify_false_when_body_was_reserialized():
    # Simulates the real bug: the handler parsed the JSON, then re-dumped it
    # with different key order and spacing before checking the signature.
    raw_body = '{"id":1,"note":null}'
    header = sign(raw_body)
    reserialized_body = '{"note": null, "id": 1}'
    assert verify_hmac(reserialized_body, header, SECRET) is False


def test_verify_false_with_wrong_secret():
    raw_body = '{"id":1}'
    header = sign(raw_body, secret="wrong_secret")
    assert verify_hmac(raw_body, header, SECRET) is False


def test_verify_false_with_missing_header():
    assert verify_hmac('{"id":1}', "", SECRET) is False
webhook-hmac.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import { verifyHmac } from "./verify-webhook-hmac.js";

const SECRET = "shpss_test_secret";

function sign(rawBody, secret = SECRET) {
  return crypto.createHmac("sha256", secret).update(Buffer.from(rawBody, "utf8")).digest("base64");
}

test("verifyHmac is true for the correct raw body", () => {
  const rawBody = '{"id":1,"note":null}';
  const header = sign(rawBody);
  assert.equal(verifyHmac(rawBody, header, SECRET), true);
});

test("verifyHmac is false when the body was re-serialized", () => {
  // Simulates the real bug: the handler parsed the JSON, then re-stringified it
  // with different key order and spacing before checking the signature.
  const rawBody = '{"id":1,"note":null}';
  const header = sign(rawBody);
  const reserializedBody = '{"note": null, "id": 1}';
  assert.equal(verifyHmac(reserializedBody, header, SECRET), false);
});

test("verifyHmac is false with the wrong secret", () => {
  const rawBody = '{"id":1}';
  const header = sign(rawBody, "wrong_secret");
  assert.equal(verifyHmac(rawBody, header, SECRET), false);
});

test("verifyHmac is false with a missing header", () => {
  assert.equal(verifyHmac('{"id":1}', "", SECRET), false);
});

Case studies

Express body parser

The app that rejected every order webhook for a week

A small fulfillment app added a global app.use(express.json()) for its regular REST routes and reused the same Express instance for its Shopify webhook endpoint. Every webhook came back a 401, the team assumed the client secret had rotated, rotated it twice, and the failures never stopped.

The actual fix was one line: mount express.raw({ type: "application/json" }) on the webhook route specifically, ahead of the global JSON parser, so the raw buffer survives long enough to verify. Once that landed, every previously rejected topic started passing immediately, with the same secret they had before touching anything.

Registered as XML

The integration that copied a config from an older project

A team stood up a new webhook subscription by copying a script from an older, XML-based integration, and it created the subscription with format: XML without noticing. Their new handler expected JSON and computed its signature check against a JSON-shaped assumption, so verification failed on every delivery even though the secret and the raw body handling were both correct.

Running the audit job listed the subscription with format: XML in plain sight next to correctly configured ones. Switching it with webhookSubscriptionUpdate fixed it without deleting and recreating the subscription or losing any delivery history.

What good looks like

After this fix, the raw body is captured before any parsing touches it, the HMAC check compares those exact bytes to the header in constant time, and the audit job keeps every webhook subscription on the JSON format the check expects. Verification passes for every legitimate Shopify request, rejects anything else, and nobody has to rotate a secret that was never the problem.

FAQ

Why does my Shopify webhook HMAC verification fail even though the secret is correct?

The most common cause is that something read the request body, parsed it as JSON, and then your verification code hashed a re-serialized copy of that JSON instead of the original bytes. Shopify signs the exact bytes it sent, so a different key order, different spacing, or a re-encoded unicode character produces a different hash even though the payload looks the same.

Is it safe to verify webhooks with a script that touches production traffic?

Yes, because verification is a pure, read-only check. It never writes to Shopify or to your database. The only thing it decides is whether to accept or reject a request, so you can unit test it completely offline and be confident it behaves the same way in production.

What is the difference between the client secret and the webhook signing secret?

For most apps they are the same value, your app's client secret from the Partner Dashboard, which Shopify also uses to sign webhook payloads. Some setups issue a separate webhook signing secret for topic-specific or Admin API created subscriptions. Either way, the HMAC check uses whichever secret Shopify tells you to use for that webhook, and mixing them up produces the same symptom as parsing the body too early.

Related field notes

Citations

On the problem:

  1. Shopify developer docs: verifying webhooks and computing the HMAC digest from the raw request body. shopify.dev/docs/apps/build/webhooks/verify-webhooks
  2. Shopify developer docs: the X-Shopify-Hmac-Sha256 header and the full set of webhook headers. shopify.dev/docs/apps/build/webhooks
  3. Shopify Community: webhook HMAC validation fails despite a correct secret, usually a body parsing order issue. community.shopify.dev

On the solution:

  1. Shopify Admin GraphQL: the webhookSubscriptions query, including the format field. shopify.dev/docs/api/admin-graphql/latest/queries/webhookSubscriptions
  2. Shopify Admin GraphQL: the webhookSubscriptionUpdate mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/webhookSubscriptionUpdate
  3. Node.js docs: crypto.timingSafeEqual for constant time comparisons. nodejs.org/api/crypto.html

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this stop your webhooks from bouncing?

If this saved you from rotating a secret that was never broken, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopify field notes