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.
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.
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:
- A global JSON body parser, such as Express's
express.json()or a framework default, runs on every route before your webhook route gets a chance to read the raw body. - A logging or debugging middleware reads the request stream to print it, which can consume the body so your handler only ever sees the parsed result.
- The verification code calls
JSON.stringify(req.body)orjson.dumps(parsed)to "reconstruct" the raw payload, not realizing that re-encoding is almost never byte-identical to the original. - The webhook subscription itself is registered with
format: XMLinstead ofJSON, so the body Shopify sends does not match what a JSON-oriented verification snippet expects at all. - The wrong secret is used, most often pasting the API key instead of the client secret, or using a secret from a different app or a different store.
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.
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.
Build it step by step
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.
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
// 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
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.
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
...
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
...
});
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.
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")
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");
}
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.
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)
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);
}
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.
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"]
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");
}
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.
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 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 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.
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
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
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.
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.
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:
- Shopify developer docs: verifying webhooks and computing the HMAC digest from the raw request body. shopify.dev/docs/apps/build/webhooks/verify-webhooks
- Shopify developer docs: the X-Shopify-Hmac-Sha256 header and the full set of webhook headers. shopify.dev/docs/apps/build/webhooks
- Shopify Community: webhook HMAC validation fails despite a correct secret, usually a body parsing order issue. community.shopify.dev
On the solution:
- Shopify Admin GraphQL: the
webhookSubscriptionsquery, including theformatfield. shopify.dev/docs/api/admin-graphql/latest/queries/webhookSubscriptions - Shopify Admin GraphQL: the
webhookSubscriptionUpdatemutation. shopify.dev/docs/api/admin-graphql/latest/mutations/webhookSubscriptionUpdate - Node.js docs:
crypto.timingSafeEqualfor 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.
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