Diagnostic Webhooks
Webhook payload not verified
Your app registered a webhook, BigCommerce POSTs order and product events to the destination URL, and the handler trusts whatever body shows up. It looks fine until someone finds that URL and sends a payload of their own. BigCommerce webhook callbacks are not signed with a documented, verifiable HMAC the way some other platforms sign their webhooks, so a handler that trusts the JSON body without checking anything else has no real way to know the request came from BigCommerce at all. Here is why that gap exists and a small check that closes it with the one safeguard BigCommerce actually supports.
BigCommerce webhook callbacks include a hash field, but BigCommerce has never published a supported formula for validating it, so it is not real verification no matter how it looks. The actual documented safeguard is the optional headers object you can set when creating the hook with POST /v3/hooks. BigCommerce echoes that header back on every callback as a shared secret. If you never set it, or your handler never checks it before acting on the payload, anyone who finds your destination URL can POST a forged scope, data.id, and store_id that your app will treat as real. Full code, tests, and a dry run guard are below.
The problem in plain words
A webhook is only useful if you can trust where it came from. Some platforms sign the payload with an HMAC computed from a secret only you and the platform know, so your handler can recompute that signature and compare it before doing anything. BigCommerce webhook callbacks do carry a hash field that looks like it might be that signature. It is described as a JSON and SHA hash of the body, but BigCommerce has never published the formula, the key, or a supported way to recompute and check it. Treating its presence as proof of anything is a guess, not verification.
What BigCommerce actually gives you is different and easy to miss. When you create a hook with POST /v3/hooks, you can pass a headers object, a set of header name and value pairs. BigCommerce stores that and sends the same headers back on every subsequent callback to that hook's destination. That is the shared secret mechanism. If a developer never sets headers when creating the hook, there is nothing to check, and the endpoint has to accept the body on faith. If the header is set but the handler never compares it, or compares it only after already acting on the payload, the protection exists on paper but never actually runs.
Why it happens
Nothing in the delivery mechanism forces verification on you, and the payload itself invites the mistake. A few ordinary ways stores end up exposed:
- The hook was created with
POST /v3/hooksand noheadersobject at all, so there is no shared secret provisioned for the endpoint to check against in the first place. - A developer sees the
hashfield in the payload, assumes it is an HMAC like other platforms use, and treats its mere presence as verification, even though BigCommerce publishes no formula to recompute or validate it. - A secret header was configured, but the receiver code reads and acts on
store_id,producer,scope,data.type, anddata.id, for example callingPUT /v2/orders/{id}to changestatus_idor a V3 inventory adjustment, before the header comparison ever runs. - The header check exists somewhere in the code, but the comparison is a plain string equality rather than constant-time, or it runs after the mutation instead of before it, so it protects nothing in practice.
This is a common source of confusion because the payload looks self-verifying. Store owners and developers alike assume a hash field means BigCommerce is proving authenticity the way a signed webhook would, when the documented mechanism is really the header BigCommerce echoes back, and only if you asked for one. See the citations at the end for the exact references.
You cannot make BigCommerce publish an HMAC formula it does not document, and you should not build trust on the hash field pretending it is one. The safe pattern is not "check that the payload has a hash." It is "provision a shared secret header on every hook, and reject any callback whose header does not match, before touching any data." We do that with a pure classification function that separates the decision from the HTTP handling, so the logic is easy to read and easy to test.
The fix, as a flow
We do not change anything about how BigCommerce delivers webhooks. We make sure every hook is created or updated with a headers secret, and we add a check in front of the handler that rejects any inbound request whose header does not match that secret, using constant-time comparison, before any order or inventory mutation runs.
Build it step by step
Get a BigCommerce API access token
Create an API account in your BigCommerce control panel under Settings, API, and generate a token with the Webhooks read and write scopes. Keep the store hash and the token in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export WEBHOOK_SECRET_HEADER_NAME="X-Webhook-Secret"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export WEBHOOK_SECRET_HEADER_NAME="X-Webhook-Secret"
export DRY_RUN="true" // start safe, change to false to write
Talk to the BigCommerce REST API
Every call carries your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper sends the request, raises on a non-2xx response, and returns the parsed body. We reuse this helper to list and provision hooks.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
Find hooks with no secret provisioned
Call GET /v3/hooks and check the headers field on each record. A hook whose headers is empty or missing has nothing configured to check against, so every callback to it must be treated as unverifiable no matter how trustworthy the body looks.
def all_hooks():
resp = bc("GET", "/v3/hooks?limit=250")
return (resp or {}).get("data", [])
def hooks_missing_secret():
"""Hooks with no headers object at all, meaning nothing was ever provisioned to check."""
return [h for h in all_hooks() if not h.get("headers")]
async function allHooks() {
const resp = await bc("GET", "/v3/hooks?limit=250");
return resp?.data || [];
}
async function hooksMissingSecret() {
// Hooks with no headers object at all, meaning nothing was ever provisioned to check.
return (await allHooks()).filter((h) => !h.headers || Object.keys(h.headers).length === 0);
}
Decide, with one pure function
Keep the trust decision in its own function that takes the hook's configured headers and the incoming request's headers, secret key name, and whether a mutation already ran before the check. It never touches the network. A pure function like this is easy to read and easy to test, which we do later.
import hmac
def classify_webhook_request(hook, incoming):
"""hook: {"headers": {name: value, ...}} or {} if none configured.
incoming: {"headers": {...}, "secretKeyName": str, "mutationRanBeforeCheck": bool}
"""
hook_headers = hook.get("headers") or {}
if not hook_headers:
return "UNVERIFIABLE_NO_SECRET"
key = incoming["secretKeyName"]
expected = hook_headers.get(key)
actual = (incoming.get("headers") or {}).get(key)
if expected is None or actual is None or not hmac.compare_digest(expected, actual):
return "REJECT_MISMATCH"
if incoming.get("mutationRanBeforeCheck"):
return "REJECT_USED_BEFORE_CHECK"
return "TRUSTED"
import { timingSafeEqual } from "node:crypto";
function constantTimeEqual(a, b) {
const bufA = Buffer.from(String(a), "utf8");
const bufB = Buffer.from(String(b), "utf8");
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
export function classifyWebhookRequest(hook, incoming) {
const hookHeaders = hook.headers || {};
if (Object.keys(hookHeaders).length === 0) return "UNVERIFIABLE_NO_SECRET";
const key = incoming.secretKeyName;
const expected = hookHeaders[key];
const actual = (incoming.headers || {})[key];
if (expected == null || actual == null || !constantTimeEqual(expected, actual)) {
return "REJECT_MISMATCH";
}
if (incoming.mutationRanBeforeCheck) return "REJECT_USED_BEFORE_CHECK";
return "TRUSTED";
}
Provision the secret on hooks that are missing one
For a hook whose headers is empty, generate a secret and set it with PUT /v3/hooks/{id} alongside is_active: true. Never silently rotate a secret that already exists, since that would break a receiver that already checks it correctly. Only fill the gap.
import secrets
def provision_secret(hook_id, header_name):
value = secrets.token_hex(32)
return bc("PUT", f"/v3/hooks/{hook_id}", json={
"headers": {header_name: value},
"is_active": True,
})
import { randomBytes } from "node:crypto";
async function provisionSecret(hookId, headerName) {
const value = randomBytes(32).toString("hex");
return bc("PUT", `/v3/hooks/${hookId}`, {
headers: { [headerName]: value },
is_active: true,
});
}
Wire it together with a dry run guard
The scan job lists every hook, logs the ones missing a secret, and only writes a new one when DRY_RUN is off. Leave DRY_RUN=true on the first runs so it only reports which hook ids would be provisioned. The receiver-side check in step 4 is app code that always runs, and it must execute before any order or inventory mutation, not folded into the API scan job.
Always start with DRY_RUN=true. Never overwrite a hook's existing headers secret without confirming it is actually missing, and always run the constant-time header comparison before any call that mutates state, such as PUT /v2/orders/{id} or a V3 inventory adjustment.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and keeps the trust decision as a pure, always-on check that never depends on the BigCommerce API being reachable.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Classify BigCommerce webhook requests so a handler never trusts an unverified payload.
BigCommerce webhook callbacks carry a hash field, but BigCommerce has never published
a supported formula for validating it, so its presence is not real verification. The
documented safeguard is the optional headers object you set when creating a hook with
POST /v3/hooks, which BigCommerce echoes back on every callback as a shared secret.
This scans hooks for a missing secret, provisions one when confirmed missing, and
exposes a pure classification function a receiver can use before ever acting on the
payload. Run the scan on a schedule. Safe to run again and again.
"""
import os
import hmac
import secrets
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("verify_webhook_secret")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
HEADER_NAME = os.environ.get("WEBHOOK_SECRET_HEADER_NAME", "X-Webhook-Secret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
def classify_webhook_request(hook, incoming):
"""Pure decision function. No network calls.
hook: {"headers": {name: value, ...}} or {} / missing if none configured
incoming: {"headers": {...}, "secretKeyName": str, "mutationRanBeforeCheck": bool}
"""
hook_headers = hook.get("headers") or {}
if not hook_headers:
return "UNVERIFIABLE_NO_SECRET"
key = incoming["secretKeyName"]
expected = hook_headers.get(key)
actual = (incoming.get("headers") or {}).get(key)
if expected is None or actual is None or not hmac.compare_digest(expected, actual):
return "REJECT_MISMATCH"
if incoming.get("mutationRanBeforeCheck"):
return "REJECT_USED_BEFORE_CHECK"
return "TRUSTED"
def all_hooks():
resp = bc("GET", "/v3/hooks?limit=250")
return (resp or {}).get("data", [])
def hooks_missing_secret():
"""Hooks with no headers object at all, meaning nothing was ever provisioned to check."""
return [h for h in all_hooks() if not h.get("headers")]
def provision_secret(hook_id, header_name):
value = secrets.token_hex(32)
return bc("PUT", f"/v3/hooks/{hook_id}", json={
"headers": {header_name: value},
"is_active": True,
})
def run():
fixed = 0
for hook in hooks_missing_secret():
log.warning(
"Hook %s scope=%s destination=%s has no secret header. %s",
hook.get("id"), hook.get("scope"), hook.get("destination"),
"would provision" if DRY_RUN else "provisioning",
)
if not DRY_RUN:
provision_secret(hook["id"], HEADER_NAME)
fixed += 1
log.info("Done. %d hook(s) %s.", fixed, "to provision" if DRY_RUN else "provisioned")
if __name__ == "__main__":
run()
/**
* Classify BigCommerce webhook requests so a handler never trusts an unverified payload.
*
* BigCommerce webhook callbacks carry a hash field, but BigCommerce has never published
* a supported formula for validating it, so its presence is not real verification. The
* documented safeguard is the optional headers object you set when creating a hook with
* POST /v3/hooks, which BigCommerce echoes back on every callback as a shared secret.
* This scans hooks for a missing secret, provisions one when confirmed missing, and
* exposes a pure classification function a receiver can use before ever acting on the
* payload. Run the scan on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/webhook-payload-not-verified/
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const HEADER_NAME = process.env.WEBHOOK_SECRET_HEADER_NAME || "X-Webhook-Secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
function constantTimeEqual(a, b) {
const bufA = Buffer.from(String(a), "utf8");
const bufB = Buffer.from(String(b), "utf8");
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
/**
* Pure decision function. No network calls.
* hook: { headers?: Record } or {} if none configured
* incoming: { headers: Record, secretKeyName: string, mutationRanBeforeCheck: boolean }
*/
export function classifyWebhookRequest(hook, incoming) {
const hookHeaders = hook.headers || {};
if (Object.keys(hookHeaders).length === 0) return "UNVERIFIABLE_NO_SECRET";
const key = incoming.secretKeyName;
const expected = hookHeaders[key];
const actual = (incoming.headers || {})[key];
if (expected == null || actual == null || !constantTimeEqual(expected, actual)) {
return "REJECT_MISMATCH";
}
if (incoming.mutationRanBeforeCheck) return "REJECT_USED_BEFORE_CHECK";
return "TRUSTED";
}
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
async function allHooks() {
const resp = await bc("GET", "/v3/hooks?limit=250");
return resp?.data || [];
}
async function hooksMissingSecret() {
return (await allHooks()).filter((h) => !h.headers || Object.keys(h.headers).length === 0);
}
async function provisionSecret(hookId, headerName) {
const value = randomBytes(32).toString("hex");
return bc("PUT", `/v3/hooks/${hookId}`, {
headers: { [headerName]: value },
is_active: true,
});
}
export async function run() {
let fixed = 0;
for (const hook of await hooksMissingSecret()) {
console.warn(
`Hook ${hook.id} scope=${hook.scope} destination=${hook.destination} has no secret header. ${DRY_RUN ? "would provision" : "provisioning"}`
);
if (!DRY_RUN) await provisionSecret(hook.id, HEADER_NAME);
fixed++;
}
console.log(`Done. ${fixed} hook(s) ${DRY_RUN ? "to provision" : "provisioned"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification rule is the part most worth testing, because it decides whether an inbound request is allowed to reach any order or inventory mutation. Because we kept classify_webhook_request pure, no I/O at all, the test needs no network and no BigCommerce store. It just feeds in fixture objects and checks the answer.
from verify_webhook_secret import classify_webhook_request
def hook(**over):
base = {"headers": {"X-Webhook-Secret": "correct-secret"}}
base.update(over)
return base
def incoming(**over):
base = {"headers": {"X-Webhook-Secret": "correct-secret"}, "secretKeyName": "X-Webhook-Secret", "mutationRanBeforeCheck": False}
base.update(over)
return base
def test_unverifiable_when_hook_has_no_headers():
assert classify_webhook_request({}, incoming()) == "UNVERIFIABLE_NO_SECRET"
def test_unverifiable_when_hook_headers_empty():
assert classify_webhook_request(hook(headers={}), incoming()) == "UNVERIFIABLE_NO_SECRET"
def test_reject_when_header_missing_from_request():
req = incoming(headers={})
assert classify_webhook_request(hook(), req) == "REJECT_MISMATCH"
def test_reject_when_header_value_wrong():
req = incoming(headers={"X-Webhook-Secret": "forged-value"})
assert classify_webhook_request(hook(), req) == "REJECT_MISMATCH"
def test_reject_when_mutation_ran_before_check():
req = incoming(mutationRanBeforeCheck=True)
assert classify_webhook_request(hook(), req) == "REJECT_USED_BEFORE_CHECK"
def test_trusted_when_match_and_checked_first():
assert classify_webhook_request(hook(), incoming()) == "TRUSTED"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyWebhookRequest } from "./verify-webhook-secret.js";
const hook = (over = {}) => ({ headers: { "X-Webhook-Secret": "correct-secret" }, ...over });
const incoming = (over = {}) => ({
headers: { "X-Webhook-Secret": "correct-secret" },
secretKeyName: "X-Webhook-Secret",
mutationRanBeforeCheck: false,
...over,
});
test("unverifiable when hook has no headers", () => {
assert.equal(classifyWebhookRequest({}, incoming()), "UNVERIFIABLE_NO_SECRET");
});
test("unverifiable when hook headers empty", () => {
assert.equal(classifyWebhookRequest(hook({ headers: {} }), incoming()), "UNVERIFIABLE_NO_SECRET");
});
test("reject when header missing from request", () => {
const req = incoming({ headers: {} });
assert.equal(classifyWebhookRequest(hook(), req), "REJECT_MISMATCH");
});
test("reject when header value wrong", () => {
const req = incoming({ headers: { "X-Webhook-Secret": "forged-value" } });
assert.equal(classifyWebhookRequest(hook(), req), "REJECT_MISMATCH");
});
test("reject when mutation ran before check", () => {
const req = incoming({ mutationRanBeforeCheck: true });
assert.equal(classifyWebhookRequest(hook(), req), "REJECT_USED_BEFORE_CHECK");
});
test("trusted when match and checked first", () => {
assert.equal(classifyWebhookRequest(hook(), incoming()), "TRUSTED");
});
Case studies
The fulfillment webhook anyone could call
A merchant's fulfillment app registered a store/order/created hook during setup, but the install script never passed a headers object to POST /v3/hooks. The handler trusted every payload that arrived, including the hash field, treating it as if it proved the request came from BigCommerce. A routine security review found the destination URL was guessable from a public asset path, and there was nothing stopping a forged order id from triggering a shipment.
Running the hook scan surfaced the hook with an empty headers object immediately. The team provisioned a secret with PUT /v3/hooks/{id}, deployed the constant-time header check ahead of the fulfillment call, and confirmed with GET /v3/hooks that the secret was in place before closing the finding.
The inventory sync that verified after it already wrote
A different integration did set a secret header on its hook, and the receiver did check it, but the check ran after the handler had already parsed data.id and queued a V3 inventory adjustment on a background job. A malformed or forged request could still enqueue a bad adjustment before the mismatch was ever detected, since the work was already dispatched.
Reordering the handler so the constant-time comparison ran first, before the adjustment was queued, turned REJECT_USED_BEFORE_CHECK from a real risk into a state the code could no longer reach. Nothing about the BigCommerce side needed to change, only the order of operations in the receiver.
After this is in place, every hook has a headers secret provisioned and confirmed, and the receiver rejects any request whose header does not match before it ever reads data.id, scope, or touches an order or inventory record. The hash field stays exactly what it is, informational, and the shared secret header is the one thing actually standing between your app and a forged payload.
FAQ
Does BigCommerce sign webhook payloads with an HMAC I can verify?
Not in the way some other platforms do. The payload includes a hash field, but BigCommerce has never published a supported formula for validating it, so its presence is not proof the request came from BigCommerce. The documented safeguard is the optional headers object you set when creating the hook with POST /v3/hooks, which BigCommerce echoes back on every callback as a shared secret.
What happens if I never set a headers secret on my webhook?
Anyone who discovers your destination URL can POST a forged payload with a fake scope, data.id, and store_id, and your app will act on it as if BigCommerce sent it. There is nothing provisioned to check against, so GET /v3/hooks will show an empty or missing headers object and the request must be treated as untrusted.
Is checking the shared secret header enough if I check it after processing the payload?
No. Verify-after-use is equivalent to no verification, because the order or inventory mutation already ran on unverified data by the time the check happens. The secret header comparison, done with constant-time equality, must run before any call that changes state, such as PUT /v2/orders/{id} or a V3 inventory adjustment.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Webhooks Overview. developer.bigcommerce.com/docs/integrations/webhooks
- BigCommerce Docs: Webhooks v3 API Reference. developer.bigcommerce.com/docs/rest-management/webhooks
- BigCommerce Developer Blog (Medium): The Practitioner's Guide to BigCommerce Webhooks. medium.com/bigcommerce-developer-blog the-practitioners-guide-to-bigcommerce-webhooks
On the solution:
- BigCommerce Docs (REST Management API Reference): Create a Webhook. developer.bigcommerce.com/docs/rest-management/webhooks
- BigCommerce Developer Center: Webhooks Tutorial. developer.bigcommerce.com/docs/integrations/webhooks/tutorial
- BigCommerce Docs (REST Management API Reference): Orders API. developer.bigcommerce.com/docs/rest-management/orders
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, inventory, webhooks, 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 close a gap in your webhook security?
If this saved you from a forged payload or a nasty security review finding, 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