Diagnostic Webhooks
BigCommerce uninstall webhook registration silently rejected
The app calls the BigCommerce hooks API to subscribe to the uninstall event, gets back a 400, and moves on without anyone noticing. Months later a merchant uninstalls the app and nothing happens: no cleanup, no notification, no record that they ever left. The cause is almost always a single wrong character in the scope string. Here is why BigCommerce rejects it outright instead of registering something broken, and a small script that finds every store missing a working uninstall hook and safely re-registers it.
BigCommerce's /v3/hooks endpoint validates the scope field against a fixed allow-list of exact scope strings, with no fuzzy matching or aliasing. The correct, documented scope for uninstall notification is the past-tense store/app/uninstalled, but it is common to submit the present-tense store/app/uninstall, or another near-miss copied from an old blog post or memory. Because the string does not match anything on the allow-list, BigCommerce rejects the create-webhook request with a 400 rather than registering a broken hook, so the app is never subscribed and never learns when a merchant uninstalls it. Run a small Python or Node.js script that lists every registered hook with GET /v3/hooks, checks whether any active hook has scope exactly store/app/uninstalled, and, only when you explicitly allow it, re-registers the correct scope with POST /v3/hooks. Full code, tests, and a dry run guard are below.
The problem in plain words
Registering a BigCommerce webhook is a single API call: POST /v3/hooks with a scope, a destination, and is_active. The scope field is not free text. BigCommerce checks it against a fixed list of known event names on its own servers, and there is no partial match, no case-insensitive fallback, no aliasing between similar-sounding strings. Either the string is on the list, or the request is rejected.
The scope for being told an app was removed is store/app/uninstalled, past tense, because it fires after the uninstall has already happened. It reads naturally to write it as store/app/uninstall instead, present tense, especially if you are copying from an older doc, a blog post, or just typing it from memory. That single-word difference is enough. BigCommerce does not register a hook with a best-effort guess at what you meant. It returns a 400 with a validation error mentioning scope, and if that response is not checked or logged loudly, the failure disappears. The app keeps running, the install still works, and nothing surfaces the fact that the uninstall subscription never existed.
Why it happens
The scope allow-list is exact-match only, so every one of these small mistakes produces the same silent gap:
- Submitting the present-tense
store/app/uninstallinstead of the documented past-tensestore/app/uninstalled, the single most common near-miss. - Copying a scope string from an older blog post, forum answer, or internal note that predates a naming change or was simply wrong to begin with.
- Dropping the
store/prefix, or getting the casing wrong, since the check is a literal string comparison with no normalization. - Treating the 400 response from
POST /v3/hooksas a non-fatal warning during app install, so the failure is logged quietly (or not at all) and the install flow completes successfully anyway, giving no visible signal that the subscription never happened.
This is a recurring pattern in BigCommerce's own support channel: webhooks registered with a near-miss scope get refused with a 400, and separately, developers report the uninstall webhook simply never firing, which traces back to the same root cause, a hook that was never actually created. See the citations at the end for the exact threads and the reference docs.
A 400 on POST /v3/hooks means no hook was created, full stop. There is no partial registration and no retry on BigCommerce's side. So the only reliable way to know your app is subscribed to the uninstall event is to ask GET /v3/hooks directly and check whether any returned hook has scope exactly equal to store/app/uninstalled and is_active true. Anything else, missing entirely, present but inactive, or present under a different scope string, means the app will not be told when a merchant uninstalls it.
The fix, as a flow
We do not touch the app's install flow directly. We add a job that lists a store's registered hooks, classifies the gap with one pure decision function, and, only when explicitly allowed, re-registers the correct scope without touching any existing near-miss hook.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the app's own OAuth access token. It needs a scope that can read and write webhooks. You need the store hash from the control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export UNINSTALL_WEBHOOK_URL="https://myapp.example.com/webhooks/uninstalled"
export DRY_RUN="true" # start safe, change to false to register
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export UNINSTALL_WEBHOOK_URL="https://myapp.example.com/webhooks/uninstalled"
export DRY_RUN="true" // start safe, change to false to register
Talk to the V3 Hooks REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/hooks with the token in the X-Auth-Token header and Accept: application/json. A small helper handles GET and POST and raises on a non-2xx response. We reuse it to list hooks and, when allowed, register the correct one.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def bc_post(path, body):
r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function bcPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
List every registered hook
Call GET /v3/hooks, paginated through meta.pagination, to collect every hook object the store currently has, including its scope, destination, and is_active. This is the ground truth. It does not matter what your app's config says it registered; what matters is what BigCommerce actually has on file.
def list_hooks():
hooks = []
page = 1
while True:
payload = bc_get("/hooks", {"page": page, "limit": 50})
page_hooks = payload.get("data", [])
if not page_hooks:
return hooks
hooks.extend(page_hooks)
pagination = payload.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", page):
return hooks
page += 1
async function listHooks() {
const hooks = [];
let page = 1;
while (true) {
const payload = await bcGet("/hooks", { page, limit: 50 });
const pageHooks = payload.data || [];
if (!pageHooks.length) return hooks;
hooks.push(...pageHooks);
const pagination = (payload.meta && payload.meta.pagination) || {};
if (page >= (pagination.total_pages || page)) return hooks;
page += 1;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the registered hooks list and returns one of four outcomes: ok when an active hook with the exact expected scope exists, missing when nothing matches at all, inactive when the right scope exists but is turned off, or near_miss when a hook exists with a scope that looks like it was meant to be the uninstall hook but is not the exact string. No I/O, just scanning and classifying, so it can be unit-tested against fixture lists.
EXPECTED_SCOPE = "store/app/uninstalled"
NEAR_MISS_SCOPES = {"store/app/uninstall", "app/uninstalled", "store/app/Uninstalled"}
def find_uninstall_scope_gap(registered_hooks, expected_scope=EXPECTED_SCOPE):
near_miss_hook = None
for hook in registered_hooks or []:
scope = hook.get("scope")
if scope == expected_scope:
if hook.get("is_active"):
return {"status": "ok"}
return {"status": "inactive", "hook_id": hook.get("id")}
if scope in NEAR_MISS_SCOPES and near_miss_hook is None:
near_miss_hook = hook
if near_miss_hook is not None:
return {
"status": "near_miss",
"hook_id": near_miss_hook.get("id"),
"found_scope": near_miss_hook.get("scope"),
}
return {"status": "missing"}
const EXPECTED_SCOPE = "store/app/uninstalled";
const NEAR_MISS_SCOPES = new Set(["store/app/uninstall", "app/uninstalled", "store/app/Uninstalled"]);
export function findUninstallScopeGap(registeredHooks, expectedScope = EXPECTED_SCOPE) {
let nearMissHook = null;
for (const hook of registeredHooks || []) {
const scope = hook.scope;
if (scope === expectedScope) {
if (hook.is_active) return { status: "ok" };
return { status: "inactive", hook_id: hook.id };
}
if (NEAR_MISS_SCOPES.has(scope) && nearMissHook === null) {
nearMissHook = hook;
}
}
if (nearMissHook !== null) {
return { status: "near_miss", hook_id: nearMissHook.id, found_scope: nearMissHook.scope };
}
return { status: "missing" };
}
Register the correct scope, guarded by dry run
When the gap is anything other than ok, log the store hash, the status, and, for a near_miss, the exact wrong scope string that was found. Only when DRY_RUN=false do we call POST /v3/hooks with the corrected literal store/app/uninstalled scope and confirm the 201 response. An existing near-miss hook is left exactly where it is. Its destination may be a customer-configured value, so deleting or mutating it without confirmation is not safe; the near miss is reported, not touched.
def register_uninstall_hook(destination):
body = {"scope": EXPECTED_SCOPE, "destination": destination, "is_active": True}
response = bc_post("/hooks", body)
data = response.get("data", {})
if data.get("scope") != EXPECTED_SCOPE:
raise RuntimeError(f"Unexpected response registering uninstall hook: {response}")
return data
async function registerUninstallHook(destination) {
const body = { scope: EXPECTED_SCOPE, destination, is_active: true };
const response = await bcPost("/hooks", body);
const data = response.data || {};
if (data.scope !== EXPECTED_SCOPE) {
throw new Error(`Unexpected response registering uninstall hook: ${JSON.stringify(response)}`);
}
return data;
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs the store hash, the classified status, and the near-miss scope string if one was found. Read the output, confirm the near-miss hooks it lists really are the culprit, then switch it off so the correct scope gets registered. Run it once after any app config change, and periodically afterward as a safety net.
Always start with DRY_RUN=true. This script only ever adds a new hook with the correctly spelled store/app/uninstalled scope. It never deletes or edits an existing hook, even one with an obviously wrong scope string, because that hook's destination may be a value only the merchant or app operator should touch.
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 is safe to run again and again because it only ever adds the one correctly spelled hook and never mutates anything that already exists.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Detect and repair a BigCommerce app's missing store/app/uninstalled webhook.
BigCommerce's /v3/hooks endpoint validates the scope field against a fixed
allow list of exact scope strings, with no fuzzy matching or aliasing. The
correct, documented scope for uninstall notification is the past tense
store/app/uninstalled, but it is common to submit the present tense
store/app/uninstall, or another near miss copied from an older doc, a blog
post, or memory. Because the string does not match anything on the allow
list, BigCommerce rejects the create webhook request with a 400 rather than
registering a broken hook, so the app is never subscribed and silently never
learns when a merchant uninstalls it. This job lists every hook a store has
registered, classifies whether the expected scope is present and active, and
only when explicitly allowed re-registers the correct scope. It never deletes
or mutates an existing near miss hook. Run once after any app config change
and periodically as a safety net. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/uninstall-webhook-registration-rejected/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_uninstall_webhook")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
UNINSTALL_WEBHOOK_URL = os.environ.get("UNINSTALL_WEBHOOK_URL", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EXPECTED_SCOPE = "store/app/uninstalled"
NEAR_MISS_SCOPES = {"store/app/uninstall", "app/uninstalled", "store/app/Uninstalled"}
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def bc_post(path, body):
r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def find_uninstall_scope_gap(registered_hooks: list, expected_scope: str = EXPECTED_SCOPE) -> dict:
"""Pure decision. No network, no side effects.
registered_hooks: list of hook dicts from GET /v3/hooks `data` array, each with
keys like {"id": int, "scope": str, "destination": str, "is_active": bool}.
Returns a decision record:
{"status": "ok"}
{"status": "missing"}
{"status": "inactive", "hook_id": 123}
{"status": "near_miss", "hook_id": 123, "found_scope": "store/app/uninstall"}
"""
near_miss_hook = None
for hook in registered_hooks or []:
scope = hook.get("scope")
if scope == expected_scope:
if hook.get("is_active"):
return {"status": "ok"}
return {"status": "inactive", "hook_id": hook.get("id")}
if scope in NEAR_MISS_SCOPES and near_miss_hook is None:
near_miss_hook = hook
if near_miss_hook is not None:
return {
"status": "near_miss",
"hook_id": near_miss_hook.get("id"),
"found_scope": near_miss_hook.get("scope"),
}
return {"status": "missing"}
def list_hooks():
hooks = []
page = 1
while True:
payload = bc_get("/hooks", {"page": page, "limit": 50})
page_hooks = payload.get("data", [])
if not page_hooks:
return hooks
hooks.extend(page_hooks)
pagination = payload.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", page):
return hooks
page += 1
def register_uninstall_hook(destination):
body = {"scope": EXPECTED_SCOPE, "destination": destination, "is_active": True}
response = bc_post("/hooks", body)
data = response.get("data", {})
if data.get("scope") != EXPECTED_SCOPE:
raise RuntimeError(f"Unexpected response registering uninstall hook: {response}")
return data
def run():
hooks = list_hooks()
decision = find_uninstall_scope_gap(hooks)
status = decision["status"]
if status == "ok":
log.info("store_hash=%s status=ok. Active store/app/uninstalled hook already registered.", STORE_HASH)
return
if status == "near_miss":
log.warning(
"store_hash=%s status=near_miss hook_id=%s found_scope=%s expected_scope=%s. "
"Existing hook left untouched.",
STORE_HASH, decision.get("hook_id"), decision.get("found_scope"), EXPECTED_SCOPE,
)
elif status == "inactive":
log.warning(
"store_hash=%s status=inactive hook_id=%s expected_scope=%s.",
STORE_HASH, decision.get("hook_id"), EXPECTED_SCOPE,
)
else:
log.warning("store_hash=%s status=missing expected_scope=%s.", STORE_HASH, EXPECTED_SCOPE)
if DRY_RUN:
log.info("store_hash=%s dry run: would register scope=%s destination=%s", STORE_HASH, EXPECTED_SCOPE, UNINSTALL_WEBHOOK_URL)
return
if not UNINSTALL_WEBHOOK_URL:
raise RuntimeError("UNINSTALL_WEBHOOK_URL must be set to register the uninstall hook.")
created = register_uninstall_hook(UNINSTALL_WEBHOOK_URL)
log.info("store_hash=%s registered scope=%s hook_id=%s", STORE_HASH, EXPECTED_SCOPE, created.get("id"))
if __name__ == "__main__":
run()
/**
* Detect and repair a BigCommerce app's missing store/app/uninstalled webhook.
*
* BigCommerce's /v3/hooks endpoint validates the scope field against a fixed
* allow list of exact scope strings, with no fuzzy matching or aliasing. The
* correct, documented scope for uninstall notification is the past tense
* store/app/uninstalled, but it is common to submit the present tense
* store/app/uninstall, or another near miss copied from an older doc, a blog
* post, or memory. Because the string does not match anything on the allow
* list, BigCommerce rejects the create webhook request with a 400 rather than
* registering a broken hook, so the app is never subscribed and silently
* never learns when a merchant uninstalls it. This job lists every hook a
* store has registered, classifies whether the expected scope is present and
* active, and only when explicitly allowed re-registers the correct scope. It
* never deletes or mutates an existing near miss hook. Run once after any app
* config change and periodically as a safety net.
*
* Guide: https://www.allanninal.dev/bigcommerce/uninstall-webhook-registration-rejected/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const UNINSTALL_WEBHOOK_URL = process.env.UNINSTALL_WEBHOOK_URL || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EXPECTED_SCOPE = "store/app/uninstalled";
const NEAR_MISS_SCOPES = new Set(["store/app/uninstall", "app/uninstalled", "store/app/Uninstalled"]);
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* registeredHooks: list of hook objects from GET /v3/hooks `data` array, each with
* keys like {id, scope, destination, is_active}.
* Returns a decision record:
* {status: "ok"}
* {status: "missing"}
* {status: "inactive", hook_id}
* {status: "near_miss", hook_id, found_scope}
*/
export function findUninstallScopeGap(registeredHooks, expectedScope = EXPECTED_SCOPE) {
let nearMissHook = null;
for (const hook of registeredHooks || []) {
const scope = hook.scope;
if (scope === expectedScope) {
if (hook.is_active) return { status: "ok" };
return { status: "inactive", hook_id: hook.id };
}
if (NEAR_MISS_SCOPES.has(scope) && nearMissHook === null) {
nearMissHook = hook;
}
}
if (nearMissHook !== null) {
return { status: "near_miss", hook_id: nearMissHook.id, found_scope: nearMissHook.scope };
}
return { status: "missing" };
}
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function bcPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function listHooks() {
const hooks = [];
let page = 1;
while (true) {
const payload = await bcGet("/hooks", { page, limit: 50 });
const pageHooks = payload.data || [];
if (!pageHooks.length) return hooks;
hooks.push(...pageHooks);
const pagination = (payload.meta && payload.meta.pagination) || {};
if (page >= (pagination.total_pages || page)) return hooks;
page += 1;
}
}
async function registerUninstallHook(destination) {
const body = { scope: EXPECTED_SCOPE, destination, is_active: true };
const response = await bcPost("/hooks", body);
const data = response.data || {};
if (data.scope !== EXPECTED_SCOPE) {
throw new Error(`Unexpected response registering uninstall hook: ${JSON.stringify(response)}`);
}
return data;
}
export async function run() {
const hooks = await listHooks();
const decision = findUninstallScopeGap(hooks);
const status = decision.status;
if (status === "ok") {
console.log(`store_hash=${STORE_HASH} status=ok. Active store/app/uninstalled hook already registered.`);
return;
}
if (status === "near_miss") {
console.warn(
`store_hash=${STORE_HASH} status=near_miss hook_id=${decision.hook_id} found_scope=${decision.found_scope} ` +
`expected_scope=${EXPECTED_SCOPE}. Existing hook left untouched.`
);
} else if (status === "inactive") {
console.warn(`store_hash=${STORE_HASH} status=inactive hook_id=${decision.hook_id} expected_scope=${EXPECTED_SCOPE}.`);
} else {
console.warn(`store_hash=${STORE_HASH} status=missing expected_scope=${EXPECTED_SCOPE}.`);
}
if (DRY_RUN) {
console.log(`store_hash=${STORE_HASH} dry run: would register scope=${EXPECTED_SCOPE} destination=${UNINSTALL_WEBHOOK_URL}`);
return;
}
if (!UNINSTALL_WEBHOOK_URL) {
throw new Error("UNINSTALL_WEBHOOK_URL must be set to register the uninstall hook.");
}
const created = await registerUninstallHook(UNINSTALL_WEBHOOK_URL);
console.log(`store_hash=${STORE_HASH} registered scope=${EXPECTED_SCOPE} hook_id=${created.id}`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether the script thinks your app is safely subscribed to the uninstall event. Because find_uninstall_scope_gap takes only a plain list of hook dicts and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in fixture lists and checks the classification.
from repair_uninstall_webhook import find_uninstall_scope_gap
def hook(scope, is_active=True, hook_id=1, destination="https://example.com/hook"):
return {"id": hook_id, "scope": scope, "destination": destination, "is_active": is_active}
def test_missing_when_hooks_list_is_empty():
assert find_uninstall_scope_gap([]) == {"status": "missing"}
def test_ok_when_exact_scope_is_active():
hooks = [hook("store/app/uninstalled", is_active=True, hook_id=42)]
assert find_uninstall_scope_gap(hooks) == {"status": "ok"}
def test_inactive_when_exact_scope_is_not_active():
hooks = [hook("store/app/uninstalled", is_active=False, hook_id=42)]
assert find_uninstall_scope_gap(hooks) == {"status": "inactive", "hook_id": 42}
def test_near_miss_when_present_tense_variant_exists():
hooks = [hook("store/app/uninstall", hook_id=7)]
assert find_uninstall_scope_gap(hooks) == {
"status": "near_miss",
"hook_id": 7,
"found_scope": "store/app/uninstall",
}
def test_missing_when_only_unrelated_scopes_exist():
hooks = [hook("store/order/statusUpdated", hook_id=1), hook("store/cart/updated", hook_id=2)]
assert find_uninstall_scope_gap(hooks) == {"status": "missing"}
def test_ok_takes_priority_even_if_a_near_miss_also_exists():
hooks = [hook("store/app/uninstall", hook_id=7), hook("store/app/uninstalled", hook_id=8, is_active=True)]
assert find_uninstall_scope_gap(hooks) == {"status": "ok"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { findUninstallScopeGap } from "./repair-uninstall-webhook.js";
const hook = (scope, { isActive = true, id = 1, destination = "https://example.com/hook" } = {}) => ({
id, scope, destination, is_active: isActive,
});
test("missing when hooks list is empty", () => {
assert.deepEqual(findUninstallScopeGap([]), { status: "missing" });
});
test("ok when exact scope is active", () => {
const hooks = [hook("store/app/uninstalled", { isActive: true, id: 42 })];
assert.deepEqual(findUninstallScopeGap(hooks), { status: "ok" });
});
test("inactive when exact scope is not active", () => {
const hooks = [hook("store/app/uninstalled", { isActive: false, id: 42 })];
assert.deepEqual(findUninstallScopeGap(hooks), { status: "inactive", hook_id: 42 });
});
test("near_miss when present tense variant exists", () => {
const hooks = [hook("store/app/uninstall", { id: 7 })];
assert.deepEqual(findUninstallScopeGap(hooks), { status: "near_miss", hook_id: 7, found_scope: "store/app/uninstall" });
});
test("missing when only unrelated scopes exist", () => {
const hooks = [hook("store/order/statusUpdated", { id: 1 }), hook("store/cart/updated", { id: 2 })];
assert.deepEqual(findUninstallScopeGap(hooks), { status: "missing" });
});
test("ok takes priority even if a near miss also exists", () => {
const hooks = [hook("store/app/uninstall", { id: 7 }), hook("store/app/uninstalled", { id: 8, isActive: true })];
assert.deepEqual(findUninstallScopeGap(hooks), { status: "ok" });
});
Case studies
The app whose install script had a two-year-old typo
An app's install handler had been registering webhooks since an early SDK version, and the uninstall scope had been typed as store/app/uninstall from the start, copied between environments as the app grew. Every install succeeded, every other webhook registered fine, and the 400 on that one call was swallowed by a broad try or except around the whole registration loop.
Running the audit script against a sample of stores turned up near_miss on every single one, all pointing at the same wrong scope string. Fixing the literal in the install code and re-running the script with DRY_RUN=false registered the correct hook across the fleet in one pass, with the old near-miss registrations left alone and harmless.
The team that only found out from a support ticket
A merchant emailed asking why their account still showed as connected in a third-party dashboard weeks after they had uninstalled the app from their BigCommerce store. The app's own database still listed the store as active, because the cleanup logic lived entirely inside the store/app/uninstalled webhook handler, and that handler had never once been called for any store.
The audit script confirmed it in seconds: GET /v3/hooks for that store, and for every other store the app was installed on, showed missing. There had never been a working uninstall subscription. Turning off dry run registered the correct scope everywhere, and the next uninstall anywhere finally triggered the cleanup as intended.
After this runs, every store the app is installed on has exactly one active hook with the exact scope store/app/uninstalled, confirmed by asking BigCommerce directly rather than trusting the app's own install logs. Any leftover near-miss hook stays visible in the audit output as a reminder to fix the literal in the source code, but it is never silently deleted or rewritten, so nothing a merchant configured gets touched without a human deciding to.
FAQ
Why does my BigCommerce uninstall webhook registration keep failing?
BigCommerce's /v3/hooks endpoint checks the scope field against a fixed allow list of exact scope strings, with no fuzzy matching. The correct scope is the past tense store/app/uninstalled. If your code submits a near-miss variant such as the present tense store/app/uninstall, a string missing the store/ prefix, or the wrong casing, the create-hook call is rejected outright with a 400 invalid scope error and no hook is ever registered.
How do I check whether my app's uninstall webhook is actually registered?
Call GET /v3/hooks with your X-Auth-Token, paginate through meta.pagination, and look for a hook object with scope exactly equal to store/app/uninstalled and is_active set to true. If no such hook exists, or the only hook that resembles it uses a different scope string, your app is not subscribed and will not learn when a merchant uninstalls it.
Is it safe to auto-fix a missing uninstall webhook by re-registering it?
Registering the correctly spelled scope, store/app/uninstalled, is safe because it only adds a new subscription. But an existing hook with a near-miss scope should be left alone and only flagged, not deleted or mutated, because its destination may be a customer-configured value you should not touch without confirmation. That is why the script defaults to DRY_RUN=true and only re-registers the correct scope when you explicitly turn it off.
Related field notes
Citations
On the problem:
- BigCommerce Support: webhooks registered with store/app/uninstall as a scope are being refused with 400s. support.bigcommerce.com webhooks refused with 400s
- BigCommerce Support: store/app/uninstall webhook is not being triggered when I uninstall my app from store. support.bigcommerce.com uninstall webhook not triggered
- BigCommerce Support: I have received 400 Bad Request Error while webhook creating with BigCommerce API. support.bigcommerce.com 400 bad request creating webhook
On the solution:
- BigCommerce API Reference: the store/app/uninstalled webhook scope. developer.bigcommerce.com store/app/uninstalled
- BigCommerce Developer Center: Webhooks overview. developer.bigcommerce.com webhooks overview
- BigCommerce Docs: the Webhooks v3 API reference. docs.bigcommerce.com webhooks v3 API
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, 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 catch a silent webhook gap?
If this saved you from an app that never noticed a merchant had left, 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