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.

Python and Node.js BigCommerce V3 Hooks API Safe by default (dry run)
Black and white electronic device
Photo by Elimende Inagella on Unsplash
The short answer

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.

App code scope: "...uninstall" POST /v3/hooks scope checked server side No allow-list match 400 invalid scope no hook created Uninstall never seen
The submitted scope string is a near miss of the real one. BigCommerce does not guess. It rejects the request and no hook is ever created.

Why it happens

The scope allow-list is exact-match only, so every one of these small mistakes produces the same silent gap:

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.

The key insight

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.

Audit job runs per store GET /v3/hooks list all registered hooks Classify gap ok, missing, inactive, near miss Status is ok? yes no, log and (if allowed) repair POST /v3/hooks correct scope, DRY_RUN=false
The script only ever adds the correctly spelled scope, and only when you turn dry run off. Existing near-miss hooks are logged, never deleted or mutated.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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()
step2.js
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();
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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"}
decide.js
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" };
}
5

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.

apply.py
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
apply.js
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;
}
6

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.

Run it safe

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.

repair_uninstall_webhook.py
"""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()
repair-uninstall-webhook.js
/**
 * 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.

test_uninstall_scope_gap.py
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"}
repair-uninstall-webhook.test.js
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

Copied from an old snippet

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.

Uninstall cleanup never ran

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.

What good looks like

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:

  1. BigCommerce Support: webhooks registered with store/app/uninstall as a scope are being refused with 400s. support.bigcommerce.com webhooks refused with 400s
  2. BigCommerce Support: store/app/uninstall webhook is not being triggered when I uninstall my app from store. support.bigcommerce.com uninstall webhook not triggered
  3. 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:

  1. BigCommerce API Reference: the store/app/uninstalled webhook scope. developer.bigcommerce.com store/app/uninstalled
  2. BigCommerce Developer Center: Webhooks overview. developer.bigcommerce.com webhooks overview
  3. 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.

Contact me on LinkedIn

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

Back to all BigCommerce field notes