Reconciler Webhooks
Webhook keeps firing after its owning app or token is removed
The app is gone. The token was revoked weeks ago. But the destination endpoint keeps getting POSTed to on every order and cart event, and nobody can figure out from the BigCommerce admin why. Uninstalling an app through the Marketplace, or deleting an API account in the control panel, is supposed to cascade-delete its webhooks. Anything outside that clean path leaves them behind, still firing, still eating into the ten-webhook-per-client_id quota. Here is why the orphan slips through and a small reconciler that finds and safely clears it.
BigCommerce only cleans up a client_id's webhooks when an app is uninstalled through the App Marketplace flow, or when its API account is deleted in the control panel. Anything outside that path, a legacy store-level token, an app removed by ad-hoc token revocation, or an app that never got the store/app/uninstall event, leaves its webhooks fully intact and firing. Worse, GET /v3/hooks only returns hooks tied to the client_id of the credential making the call, so no single request shows you every webhook the store has ever registered. Run a small Python or Node.js reconciler that lists hooks per known credential, diffs each hook's client_id against your known-good set of currently installed apps, and only auto-deletes the ones that are both unowned and already deactivated. Everything else that looks orphaned but is still active gets flagged for a human. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce webhooks are scoped to the client_id that created them. Delete the app cleanly, through the Marketplace uninstall flow, or delete its API account from Settings, API in the control panel, and BigCommerce cascades that removal down to every webhook that client_id ever registered. That is the documented, supported path, and it works.
The trouble starts when an app or token disappears some other way. A legacy store-level API account gets revoked by hand instead of properly deleted. An app is pulled from a store's connected apps list without ever firing the store/app/uninstall event its own uninstall handler was relying on. A developer rotates credentials and simply stops using the old client_id, without ever calling anything to clean it up. In every one of these cases, BigCommerce has no signal that tells it "this app is gone, clear its hooks." The webhook rows sit exactly where they were created, is_active still true, still matching every event they were scoped to, still POSTing to a destination that may not even resolve anymore.
Why it happens
A few concrete ways stores end up with orphaned webhooks that keep firing:
- An app is disconnected by revoking or deleting its token directly, instead of going through the Marketplace's own uninstall flow, so the
store/app/uninstallwebhook that would normally trigger cleanup never fires. - A legacy store-level API account was used to register webhooks years ago, was later abandoned in favor of a proper app installation, but was never explicitly deleted from Settings, API in the control panel.
- An app loses marketplace access or its listing is pulled, but the store's existing installation and its client_id's webhooks are untouched, because that removal path does not run the same cleanup as a merchant-initiated uninstall.
GET /v3/hooksonly returns hooks scoped to the client_id of the credential making the call, so a store-level token reconciling its own hooks will never see a stray hook that belongs to some other, possibly removed, client_id, unless that credential is used to check too. Nobody has a single view across every client_id a store has ever issued.- Because BigCommerce caps webhooks at ten per store_id, client_id, and scope combination, an orphaned hook quietly consumes one of those ten slots, and a legitimate reinstall of the same integration can fail to register its own webhook until the quota is freed.
This is a recurring point of confusion in BigCommerce's own support channel: merchants ask whether uninstalling an app removes its webhooks, and whether there is any way to be notified when it does not. See the citations at the end for the exact threads and docs.
A webhook's own fields, is_active especially, do not tell you whether it is orphaned. BigCommerce auto-deactivates hooks after repeated delivery failures or 90 days of inactivity, so is_active=false can just as easily mean a perfectly legitimate, still-owned integration hit a rough patch. The only reliable signal is whether the hook's client_id is still in your known set of currently installed apps or trusted store-level credentials. Even then, an unrecognized client_id that is still actively firing needs a human to confirm before it gets deleted. We only auto-delete when a hook is both unowned and already deactivated for a long stretch, the two signals corroborating each other.
The fix, as a flow
We do not touch live webhook creation. We add a reconciler that lists hooks visible to each API credential you run it with, compares every hook's client_id against the set of client_ids you currently trust, and classifies each one before ever deleting anything.
Build it step by step
Get a store hash and an API access token
Use a store-level API account (or, for full coverage, one token per app credential the store has ever issued) with Webhooks read and write scope. You need the store hash from your 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 KNOWN_CLIENT_IDS="client_abc,client_def"
export STALE_AFTER_DAYS="90"
export DRY_RUN="true" # start safe, change to false to delete
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export KNOWN_CLIENT_IDS="client_abc,client_def"
export STALE_AFTER_DAYS="90"
export DRY_RUN="true" // start safe, change to false to delete
Talk to the V3 Webhooks REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/hooks with the token in the X-Auth-Token header. A small helper handles GET and DELETE and raises on a non-2xx response. V3 wraps list responses in {data, meta.pagination}, so we page through meta.pagination.links.next until it runs out.
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()
def bc_delete(path):
r = requests.delete(f"{API_BASE}{path}", headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
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}`);
return res.json();
}
async function bcDelete(path) {
const res = await fetch(`${API_BASE}${path}`, { method: "DELETE", headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
List every hook the credential can see
Call GET /v3/hooks, paginated via meta.pagination.links.next. Each item carries id, client_id, scope, destination, is_active, created_at, and updated_at. Remember this only shows hooks tied to the client_id of the token you are calling with, so run this once per credential the store has ever issued for full coverage.
def list_hooks():
page = 1
while True:
payload = bc_get("/hooks", {"page": page, "limit": 50})
items = payload.get("data") or []
if not items:
return
for hook in items:
yield hook
next_link = (payload.get("meta") or {}).get("pagination", {}).get("links", {}).get("next")
if not next_link:
return
page += 1
async function* listHooks() {
let page = 1;
while (true) {
const payload = await bcGet("/hooks", { page, limit: 50 });
const items = payload.data || [];
if (!items.length) return;
for (const hook of items) yield hook;
const nextLink = payload.meta?.pagination?.links?.next;
if (!nextLink) return;
page += 1;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the hook, the set of known client_ids, and the current time, and returns one of four outcomes. A recognized client_id is always kept. An unrecognized one is only cleared for deletion when BigCommerce itself has already marked it inactive for a long stretch, the corroborating second signal. A still-active unrecognized hook is flagged, never deleted automatically.
from typing import Literal
STALE_AFTER_DAYS_DEFAULT = 90
def classify_hook(
hook: dict, known_client_ids: set, now_epoch: int, stale_after_days: int = STALE_AFTER_DAYS_DEFAULT
) -> Literal["keep", "orphan_delete", "orphan_flag_only", "stale_inactive"]:
if hook.get("client_id") in known_client_ids:
return "keep"
is_active = bool(hook.get("is_active"))
updated_at = hook.get("updated_at") or 0
age_seconds = now_epoch - updated_at
is_stale = age_seconds > stale_after_days * 86400
if not is_active and is_stale:
return "orphan_delete"
if is_active:
return "orphan_flag_only"
return "stale_inactive"
const STALE_AFTER_DAYS_DEFAULT = 90;
export function classifyHook(hook, knownClientIds, nowEpoch, staleAfterDays = STALE_AFTER_DAYS_DEFAULT) {
if (knownClientIds.has(hook.client_id)) return "keep";
const isActive = Boolean(hook.is_active);
const updatedAt = hook.updated_at || 0;
const ageSeconds = nowEpoch - updatedAt;
const isStale = ageSeconds > staleAfterDays * 86400;
if (!isActive && isStale) return "orphan_delete";
if (isActive) return "orphan_flag_only";
return "stale_inactive";
}
Delete only the confirmed orphans
When the classification is orphan_delete, call DELETE /v3/hooks/{webhook_id}, which returns the deleted hook as 200 OK. Anything flagged is logged with enough detail, id, client_id, scope, destination, is_active, created_at, for a human to look it up in the control panel or cross-check the destination host against a known app domain before deciding anything.
def delete_hook(hook_id):
return bc_delete(f"/hooks/{hook_id}")
async function deleteHook(hookId) {
return bcDelete(`/hooks/${hookId}`);
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the {id, client_id, scope, destination, is_active, created_at} tuple for each hook it would delete, and each hook it would flag. Read the output, agree with it, then switch it off. Run it on a schedule, weekly is plenty, since orphaned hooks accumulate slowly.
Always start with DRY_RUN=true, and never let the job delete a hook on is_active=false alone. That flag can just mean BigCommerce's own 48-hour-retry or 90-day-inactivity auto-deactivation kicked in on a hook that is still legitimately owned. Require the client_id-not-in-known-apps condition first, corroborated by the staleness window, before anything gets deleted automatically.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only deletes hooks that are both unowned and already deactivated for a long stretch, flagging every other unrecognized client_id for a human instead.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and safely clear BigCommerce webhooks orphaned by app or token removal.
Uninstalling an app through the App Marketplace flow, or deleting an API account
through the control panel, cascades to delete that client_id's webhooks. Every
other way an app or token disappears, an ad-hoc token revocation, a legacy
store-level credential, or an app that never received the store/app/uninstall
event, leaves its webhooks fully intact and still firing. GET /v3/hooks only
returns hooks tied to the client_id of the credential making the call, so no
single request shows every webhook a store has ever registered. This job lists
hooks visible to the configured credential, diffs each hook's client_id against
a known-good set of currently installed apps, and only deletes a hook when it is
both unowned and already deactivated by BigCommerce for a long stretch. A still
active, unrecognized hook is flagged for a human, never deleted automatically.
Run on a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/orphaned-webhooks-after-token-removal/
"""
import os
import time
import logging
from typing import Literal
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_orphaned_webhooks")
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"
KNOWN_CLIENT_IDS = {
c.strip() for c in os.environ.get("KNOWN_CLIENT_IDS", "").split(",") if c.strip()
}
STALE_AFTER_DAYS = int(os.environ.get("STALE_AFTER_DAYS", "90"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
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()
def bc_delete(path):
r = requests.delete(f"{API_BASE}{path}", headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def classify_hook(
hook: dict, known_client_ids: set, now_epoch: int, stale_after_days: int = 90
) -> Literal["keep", "orphan_delete", "orphan_flag_only", "stale_inactive"]:
"""Pure decision. No network, no side effects.
hook: {id, client_id, scope, destination, is_active, created_at, updated_at}
known_client_ids: set of client_id strings for currently installed/authorized
apps (or the single store-level client_id set the operator trusts).
1. If hook['client_id'] in known_client_ids -> 'keep'.
2. Else (client_id not recognized):
a. If hook['is_active'] is False and (now_epoch - hook['updated_at'])
> stale_after_days*86400 -> 'orphan_delete' (safe: already
deactivated by BigCommerce AND unowned).
b. Elif hook['is_active'] is True -> 'orphan_flag_only' (still firing
for an unrecognized owner; needs human confirm before delete).
c. Else -> 'stale_inactive' (recently deactivated, unrecognized owner,
but not old enough to auto-clear).
"""
if hook.get("client_id") in known_client_ids:
return "keep"
is_active = bool(hook.get("is_active"))
updated_at = hook.get("updated_at") or 0
age_seconds = now_epoch - updated_at
is_stale = age_seconds > stale_after_days * 86400
if not is_active and is_stale:
return "orphan_delete"
if is_active:
return "orphan_flag_only"
return "stale_inactive"
def list_hooks():
"""Page through every hook visible to the configured credential."""
page = 1
while True:
payload = bc_get("/hooks", {"page": page, "limit": 50})
items = payload.get("data") or []
if not items:
return
for hook in items:
yield hook
next_link = (payload.get("meta") or {}).get("pagination", {}).get("links", {}).get("next")
if not next_link:
return
page += 1
def delete_hook(hook_id):
return bc_delete(f"/hooks/{hook_id}")
def run():
deleted = 0
flagged = 0
stale = 0
now_epoch = int(time.time())
for hook in list_hooks():
decision = classify_hook(hook, KNOWN_CLIENT_IDS, now_epoch, STALE_AFTER_DAYS)
if decision == "keep":
continue
if decision == "stale_inactive":
log.info(
"Hook %s (client_id=%s) is recently inactive but not old enough to clear yet.",
hook.get("id"), hook.get("client_id"),
)
stale += 1
continue
if decision == "orphan_flag_only":
log.warning(
"Hook %s flagged for review. client_id=%s scope=%s destination=%s "
"is_active=%s created_at=%s",
hook.get("id"), hook.get("client_id"), hook.get("scope"),
hook.get("destination"), hook.get("is_active"), hook.get("created_at"),
)
flagged += 1
continue
log.info(
"id=%s client_id=%s scope=%s destination=%s is_active=%s created_at=%s (%s)",
hook.get("id"), hook.get("client_id"), hook.get("scope"),
hook.get("destination"), hook.get("is_active"), hook.get("created_at"),
"dry run" if DRY_RUN else "deleting",
)
if not DRY_RUN:
delete_hook(hook.get("id"))
deleted += 1
log.info(
"Done. %d hook(s) %s, %d hook(s) flagged for review, %d hook(s) stale but not yet clearable.",
deleted, "to delete" if DRY_RUN else "deleted", flagged, stale,
)
if __name__ == "__main__":
run()
/**
* Find and safely clear BigCommerce webhooks orphaned by app or token removal.
*
* Uninstalling an app through the App Marketplace flow, or deleting an API
* account through the control panel, cascades to delete that client_id's
* webhooks. Every other way an app or token disappears, an ad-hoc token
* revocation, a legacy store-level credential, or an app that never received
* the store/app/uninstall event, leaves its webhooks fully intact and still
* firing. GET /v3/hooks only returns hooks tied to the client_id of the
* credential making the call, so no single request shows every webhook a
* store has ever registered. This job lists hooks visible to the configured
* credential, diffs each hook's client_id against a known-good set of
* currently installed apps, and only deletes a hook when it is both unowned
* and already deactivated by BigCommerce for a long stretch. A still active,
* unrecognized hook is flagged for a human, never deleted automatically.
* Run on a schedule.
*
* Guide: https://www.allanninal.dev/bigcommerce/orphaned-webhooks-after-token-removal/
*/
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 KNOWN_CLIENT_IDS = new Set(
(process.env.KNOWN_CLIENT_IDS || "")
.split(",")
.map((c) => c.trim())
.filter(Boolean)
);
const STALE_AFTER_DAYS = Number(process.env.STALE_AFTER_DAYS || 90);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* hook: {id, client_id, scope, destination, is_active, created_at, updated_at}
* knownClientIds: Set of client_id strings for currently installed/authorized
* apps (or the single store-level client_id set the operator trusts).
*
* 1. If hook.client_id is in knownClientIds -> "keep".
* 2. Else (client_id not recognized):
* a. If hook.is_active is false and (nowEpoch - hook.updated_at)
* > staleAfterDays*86400 -> "orphan_delete" (safe: already
* deactivated by BigCommerce AND unowned).
* b. Else if hook.is_active is true -> "orphan_flag_only" (still firing
* for an unrecognized owner; needs human confirm before delete).
* c. Else -> "stale_inactive" (recently deactivated, unrecognized owner,
* but not old enough to auto-clear).
*/
export function classifyHook(hook, knownClientIds, nowEpoch, staleAfterDays = 90) {
if (knownClientIds.has(hook.client_id)) return "keep";
const isActive = Boolean(hook.is_active);
const updatedAt = hook.updated_at || 0;
const ageSeconds = nowEpoch - updatedAt;
const isStale = ageSeconds > staleAfterDays * 86400;
if (!isActive && isStale) return "orphan_delete";
if (isActive) return "orphan_flag_only";
return "stale_inactive";
}
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}`);
return res.json();
}
async function bcDelete(path) {
const res = await fetch(`${API_BASE}${path}`, { method: "DELETE", headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function* listHooks() {
let page = 1;
while (true) {
const payload = await bcGet("/hooks", { page, limit: 50 });
const items = payload.data || [];
if (!items.length) return;
for (const hook of items) yield hook;
const nextLink = payload.meta?.pagination?.links?.next;
if (!nextLink) return;
page += 1;
}
}
async function deleteHook(hookId) {
return bcDelete(`/hooks/${hookId}`);
}
export async function run() {
let deleted = 0;
let flagged = 0;
let stale = 0;
const nowEpoch = Math.floor(Date.now() / 1000);
for await (const hook of listHooks()) {
const decision = classifyHook(hook, KNOWN_CLIENT_IDS, nowEpoch, STALE_AFTER_DAYS);
if (decision === "keep") continue;
if (decision === "stale_inactive") {
console.log(
`Hook ${hook.id} (client_id=${hook.client_id}) is recently inactive but not old enough to clear yet.`
);
stale += 1;
continue;
}
if (decision === "orphan_flag_only") {
console.warn(
`Hook ${hook.id} flagged for review. client_id=${hook.client_id} scope=${hook.scope} ` +
`destination=${hook.destination} is_active=${hook.is_active} created_at=${hook.created_at}`
);
flagged += 1;
continue;
}
console.log(
`id=${hook.id} client_id=${hook.client_id} scope=${hook.scope} destination=${hook.destination} ` +
`is_active=${hook.is_active} created_at=${hook.created_at} (${DRY_RUN ? "dry run" : "deleting"})`
);
if (!DRY_RUN) await deleteHook(hook.id);
deleted += 1;
}
console.log(
`Done. ${deleted} hook(s) ${DRY_RUN ? "to delete" : "deleted"}, ${flagged} hook(s) flagged for review, ` +
`${stale} hook(s) stale but not yet clearable.`
);
}
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 a real webhook gets deleted. Because classify_hook takes only plain values and returns a plain string, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from reconcile_orphaned_webhooks import classify_hook
NOW = 1_800_000_000
DAY = 86400
def make_hook(client_id="client_unknown", is_active=False, updated_at=NOW):
return {
"id": 1,
"client_id": client_id,
"scope": "store/order/*",
"destination": "https://example.com/hooks",
"is_active": is_active,
"created_at": updated_at,
"updated_at": updated_at,
}
def test_keep_when_client_id_is_known():
hook = make_hook(client_id="client_known")
assert classify_hook(hook, {"client_known"}, NOW) == "keep"
def test_orphan_delete_when_unowned_and_stale_inactive():
hook = make_hook(is_active=False, updated_at=NOW - 91 * DAY)
assert classify_hook(hook, {"client_known"}, NOW) == "orphan_delete"
def test_orphan_flag_only_when_unowned_and_still_active():
hook = make_hook(is_active=True, updated_at=NOW - 200 * DAY)
assert classify_hook(hook, {"client_known"}, NOW) == "orphan_flag_only"
def test_stale_inactive_when_unowned_but_recently_deactivated():
hook = make_hook(is_active=False, updated_at=NOW - 10 * DAY)
assert classify_hook(hook, {"client_known"}, NOW) == "stale_inactive"
def test_orphan_delete_respects_custom_stale_after_days():
hook = make_hook(is_active=False, updated_at=NOW - 31 * DAY)
assert classify_hook(hook, {"client_known"}, NOW, stale_after_days=30) == "orphan_delete"
def test_keep_wins_even_if_hook_would_otherwise_look_orphaned():
hook = make_hook(client_id="client_known", is_active=False, updated_at=NOW - 500 * DAY)
assert classify_hook(hook, {"client_known"}, NOW) == "keep"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyHook } from "./reconcile-orphaned-webhooks.js";
const NOW = 1_800_000_000;
const DAY = 86400;
const makeHook = ({ clientId = "client_unknown", isActive = false, updatedAt = NOW } = {}) => ({
id: 1,
client_id: clientId,
scope: "store/order/*",
destination: "https://example.com/hooks",
is_active: isActive,
created_at: updatedAt,
updated_at: updatedAt,
});
test("keep when client_id is known", () => {
const hook = makeHook({ clientId: "client_known" });
assert.equal(classifyHook(hook, new Set(["client_known"]), NOW), "keep");
});
test("orphan_delete when unowned and stale inactive", () => {
const hook = makeHook({ isActive: false, updatedAt: NOW - 91 * DAY });
assert.equal(classifyHook(hook, new Set(["client_known"]), NOW), "orphan_delete");
});
test("orphan_flag_only when unowned and still active", () => {
const hook = makeHook({ isActive: true, updatedAt: NOW - 200 * DAY });
assert.equal(classifyHook(hook, new Set(["client_known"]), NOW), "orphan_flag_only");
});
test("stale_inactive when unowned but recently deactivated", () => {
const hook = makeHook({ isActive: false, updatedAt: NOW - 10 * DAY });
assert.equal(classifyHook(hook, new Set(["client_known"]), NOW), "stale_inactive");
});
test("orphan_delete respects custom staleAfterDays", () => {
const hook = makeHook({ isActive: false, updatedAt: NOW - 31 * DAY });
assert.equal(classifyHook(hook, new Set(["client_known"]), NOW, 30), "orphan_delete");
});
test("keep wins even if hook would otherwise look orphaned", () => {
const hook = makeHook({ clientId: "client_known", isActive: false, updatedAt: NOW - 500 * DAY });
assert.equal(classifyHook(hook, new Set(["client_known"]), NOW), "keep");
});
Case studies
The store that revoked a token instead of deleting the API account
A merchant's IT team rotated an integration's credentials by revoking the old token from a password manager entry, without ever visiting Settings, API in the BigCommerce control panel to delete the API account itself. Months later, order and cart webhooks kept hitting a decommissioned server, filling up its error logs and quietly holding two of the ten webhook slots for a client_id nobody could even find in the current app list.
The reconciler, run with the store's current known-good client_ids, flagged both hooks immediately as unowned. Because they were still is_active=true, they were held for review rather than auto-deleted. Once a human confirmed the client_id belonged to the retired integration, the flagged list gave them exactly the hook ids to remove.
The app that lost marketplace access without an uninstall event
A third-party app's listing was pulled from the BigCommerce Marketplace after a policy issue on the vendor's side. The store's existing installation was never explicitly uninstalled through the normal flow, so its webhooks kept running for weeks, firing to a destination that had already gone dark on the vendor's end.
Because those hooks had gone quiet and BigCommerce's own 90-day inactivity auto-deactivation had already kicked in by the time anyone investigated, the reconciler classified them as orphan_delete: unowned by any currently known client_id, and already deactivated for well over the stale window. They were the only hooks removed automatically; everything still active was left for a human.
After this runs on a schedule, every hook whose client_id you no longer recognize gets seen, whether it is quietly firing to a dead endpoint or sitting deactivated and forgotten. Only the ones BigCommerce itself has already stopped delivering to, and that belong to nobody you currently trust, get cleared automatically. Anything still active but unrecognized stays exactly where it is, flagged for a human, so a legitimate integration you simply forgot to record never gets deleted by mistake, and your ten-webhook-per-client_id quota stops leaking to hooks nobody owns anymore.
FAQ
Why does a BigCommerce webhook keep firing after I removed the app?
BigCommerce only cascades a clean delete of a client_id's webhooks when the app is uninstalled through the Marketplace flow, or when its API account is deleted in the control panel. If the app lost access some other way, an ad-hoc token revocation, a legacy store-level token, or an app that never received the store/app/uninstall event, its webhooks are left fully intact and keep firing normally.
Why can I not just list every webhook my store has and see the orphans right away?
GET /v3/hooks only returns hooks tied to the client_id of the credential making the call. There is no public v3 endpoint that lists every client_id a store has ever issued alongside its webhooks, so a single store-level token can only ever see its own slice. Full coverage means running the reconciler once per API account or app credential the store has ever had, and comparing what it sees against the merchant's own record of currently installed apps.
Is it safe to auto-delete every webhook whose client_id I do not recognize?
No. An unrecognized client_id that is still active should be flagged for a human to confirm, not deleted automatically, because is_active alone can be misleading and a currently-firing hook might still belong to an app you simply forgot to record. Only delete automatically when the hook is both unowned (client_id not in the known set) and already deactivated by BigCommerce for a long stretch, which is the safest, most corroborated signal that it is a true orphan.
Related field notes
Citations
On the problem:
- BigCommerce Support: is there a way to delete webhooks on app uninstall. support.bigcommerce.com is there a way to delete webhooks on app uninstall
- BigCommerce Support: during app uninstall from a client will I be able to remove the webhooks. support.bigcommerce.com during app uninstall will I be able to remove the webhooks
- BigCommerce Support: how to avoid webhooks deactivation, it happens periodically. support.bigcommerce.com how to avoid webhooks deactivation
On the solution:
- BigCommerce Developer Docs: Webhooks Overview, retry mechanism, is_active, post app uninstall actions. docs.bigcommerce.com webhooks overview
- BigCommerce API Reference: List Webhooks, GET /v3/hooks. docs.bigcommerce.com list webhooks
- BigCommerce API Reference: Delete Webhook, DELETE /v3/hooks/{webhook_id}. docs.bigcommerce.com delete webhook
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 clear your orphaned webhooks?
If this saved you a mystery-endpoint hunt or freed up a webhook quota you thought was maxed out for good, 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