Diagnostic Webhooks

Webhook domain blocklisted after low delivery success ratio

Deliveries to a domain that was working fine a minute ago suddenly stop, for every hook registered on that host, not just the one that was failing. BigCommerce tracks a rolling success ratio per destination domain, and once it dips below 90 percent it blocklists the whole domain for 3 minutes. There is no API call to lift that block early. Here is why it happens, how to detect it from your own request log, and the one safe repair that is left once the dust settles.

Python and Node.js BigCommerce V3 Hooks API Safe by default (dry run)
Server nameplates
Photo by Marc PEZIN on Unsplash
The short answer

BigCommerce's webhook dispatcher computes a rolling success versus failure ratio per destination domain over a sliding 2-minute window, but only starts evaluating it once at least 100 requests have been sent to that domain within the window. If the ratio falls below 90 percent, usually because the receiving endpoint is slow, returning non-200 responses, or intermittently down, BigCommerce blocklists the entire domain for 3 minutes, not just the failing hook. Because the block is domain-scoped, one flaky path like /webhooks/orders can starve delivery to an unrelated, healthy hook such as /webhooks/inventory on the same host. There is no safe API call to lift the block early or force a redelivery, it self-expires and BigCommerce requeues automatically. Run a small Python or Node.js script that lists hooks with GET /v3/hooks, correlates them against your own app's request log to compute rolling success ratios per domain, flags any domain at risk of blocklisting and any hook BigCommerce already deactivated, and only ever writes PUT /v3/hooks/{hook_id} with {"is_active": true} once the destination is confirmed healthy again. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce fans out webhook deliveries to whatever destinations a store has registered under /v3/hooks. To protect itself from hammering a destination that is clearly struggling, the dispatcher watches a rolling 2-minute window of delivery attempts per destination domain. Once that window has seen at least 100 requests, it checks the success ratio. If fewer than 90 percent came back as a successful delivery, the entire domain, every hook pointed at it, gets blocklisted for 3 minutes.

The part that catches people off guard is the scope of the block. It is not per hook and not per path, it is per domain. If a store has one hook on /webhooks/orders that is timing out because the receiving app is overloaded, and a completely separate, perfectly healthy hook on /webhooks/inventory on that same host, both stop receiving deliveries for the next 3 minutes. The inventory hook did nothing wrong. It just shares a domain with the hook that did.

If the underlying instability on the endpoint does not clear up, a second and unrelated failure path can also kick in: BigCommerce separately tracks delivery attempts per hook and, after repeated failures over roughly 48 hours or 11 retries, deactivates that specific hook by flipping is_active to false. That one is permanent until someone re-enables it. The 3-minute domain block and the 48-hour deactivation are two different mechanisms, but a persistently flaky endpoint can trigger both.

/webhooks/orders slow, non-200, flaky 2-min rolling ratio for this domain < 90% Domain blocklisted for 3 minutes /webhooks/orders deliveries paused /webhooks/inventory healthy, still starved same domain, both blocked
The block is scoped to the destination domain, so one flaky path can starve delivery to an unrelated, healthy hook on the same host for 3 minutes.

Why it happens

BigCommerce does not evaluate every hook in isolation while deciding whether to keep delivering. A few things line up to produce this behavior:

BigCommerce does not expose a delivery-log or success-rate endpoint for webhooks, so none of this shows up by simply listing hooks. See the citations at the end for the official webhook docs and the support thread where merchants ask how to avoid the periodic deactivation.

The key insight

There is nothing in the BigCommerce API to prevent or lift this block, because it is not a bug, it is the platform protecting itself and every other store sharing that infrastructure from a slow or broken destination. The only thing worth automating is detection: correlate GET /v3/hooks against your own receiving app's request log to compute rolling success ratios per domain, flag any domain trending toward the 90 percent floor before it trips, and separately flag any hook that already got auto-deactivated with is_active: false. The one safe write left is re-enabling a hook once its endpoint is confirmed healthy again.

The fix, as a flow

We do not fight the domain block, it is not ours to lift. We add a job that lists registered hooks, pulls matching request-log entries for each destination domain, computes the rolling success ratio, and reports anything at risk or already deactivated, only ever writing back to re-enable a hook that BigCommerce turned off and that we have confirmed is healthy again.

Scheduled job runs on a timer GET /v3/hooks destination, is_active App request log status per domain, 2-min window total >= 100 and ratio < 90%? yes no, keep watching Alert: at risk domain, ratio, window is_active false? re-enable if healthy
The job only ever reports the at-risk domain, it never tries to lift the block. The one write it makes is re-enabling a hook BigCommerce deactivated, and only after a synthetic health check passes.

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 store's existing app credentials. Grant it Webhooks (modify) scope so it can list hooks and, when needed, re-enable one. 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.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_SAMPLE="100"
export SUCCESS_THRESHOLD="0.90"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_SAMPLE="100"
export SUCCESS_THRESHOLD="0.90"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V3 Hooks REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response, and paginates through meta.pagination since /v3/hooks wraps its results in a {data, meta} envelope.

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()

def bc_put(path, body):
    r = requests.put(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}`);
  return res.json();
}

async function bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List every registered hook, paginated

Call GET /v3/hooks, paging through meta.pagination, and read id, destination, scope, is_active, and updated_at for each. BigCommerce has no delivery-log or success-rate endpoint, so this list only tells us what is registered and what BigCommerce itself already deactivated. Everything about the rolling success ratio has to come from your own app's request log.

step3.py
def list_hooks():
    page = 1
    while True:
        payload = bc_get("/hooks", {"page": page, "limit": 50})
        hooks = payload.get("data") or []
        if not hooks:
            return
        for hook in hooks:
            yield hook
        pagination = (payload.get("meta") or {}).get("pagination") or {}
        if page >= (pagination.get("total_pages") or page):
            return
        page += 1
step3.js
async function* listHooks() {
  let page = 1;
  while (true) {
    const payload = await bcGet("/hooks", { page, limit: 50 });
    const hooks = payload.data || [];
    if (!hooks.length) return;
    for (const hook of hooks) yield hook;
    const pagination = (payload.meta || {}).pagination || {};
    if (page >= (pagination.total_pages || page)) return;
    page += 1;
  }
}
4

Decide, with one pure function

Keep the evaluation in its own function that takes the delivery attempts recorded for a single rolling 2-minute window, one entry per attempt with its domain and status code, and returns the total and success ratio per domain, along with whether that domain is at risk of the block. It only starts judging a domain once it has seen at least 100 requests, exactly matching BigCommerce's own rule, so a quiet domain never gets falsely flagged just because a couple of requests failed.

evaluate.py
def evaluate_webhook_health(window_requests, min_sample=100, threshold=0.90):
    by_domain = {}
    for entry in window_requests or []:
        domain = entry["domain"]
        bucket = by_domain.setdefault(domain, {"total": 0, "success": 0})
        bucket["total"] += 1
        if 200 <= entry["status_code"] < 300:
            bucket["success"] += 1

    results = {}
    for domain, bucket in by_domain.items():
        total = bucket["total"]
        if total < min_sample:
            results[domain] = {
                "domain": domain, "total": total,
                "success_ratio": None, "at_risk": False,
            }
            continue
        ratio = bucket["success"] / total
        results[domain] = {
            "domain": domain, "total": total,
            "success_ratio": ratio, "at_risk": ratio < threshold,
        }
    return results
evaluate.js
export function evaluateWebhookHealth(windowRequests, minSample = 100, threshold = 0.90) {
  const byDomain = new Map();
  for (const entry of windowRequests || []) {
    const domain = entry.domain;
    const bucket = byDomain.get(domain) || { total: 0, success: 0 };
    bucket.total += 1;
    if (entry.status_code >= 200 && entry.status_code < 300) bucket.success += 1;
    byDomain.set(domain, bucket);
  }

  const results = {};
  for (const [domain, bucket] of byDomain.entries()) {
    if (bucket.total < minSample) {
      results[domain] = { domain, total: bucket.total, success_ratio: null, at_risk: false };
      continue;
    }
    const ratio = bucket.success / bucket.total;
    results[domain] = { domain, total: bucket.total, success_ratio: ratio, at_risk: ratio < threshold };
  }
  return results;
}
5

Report, never repair, the domain block

There is no API call that lifts a domain blocklist or forces BigCommerce to redeliver missed events. The block self-expires in 3 minutes and BigCommerce requeues automatically. So for every domain the pure function marks at_risk, emit an alert with the domain, the computed success ratio, and the window timestamps. That is the entire action, log it loudly enough that a human notices before the 90 percent floor is crossed.

report.py
def report_at_risk_domains(health_by_domain, window_start, window_end, log):
    for result in health_by_domain.values():
        if not result["at_risk"]:
            continue
        log.warning(
            "Domain %s at risk of blocklisting. success_ratio=%.3f total=%d window=%s..%s",
            result["domain"], result["success_ratio"], result["total"],
            window_start, window_end,
        )
report.js
function reportAtRiskDomains(healthByDomain, windowStart, windowEnd) {
  for (const result of Object.values(healthByDomain)) {
    if (!result.at_risk) continue;
    console.warn(
      `Domain ${result.domain} at risk of blocklisting. ` +
      `success_ratio=${result.success_ratio.toFixed(3)} total=${result.total} ` +
      `window=${windowStart}..${windowEnd}`
    );
  }
}
6

Re-enable a deactivated hook, only after a health check, guarded by dry run

When GET /v3/hooks shows is_active: false, that hook already crossed the separate 48-hour or 11-retry exhaustion threshold and BigCommerce turned it off for good. The only corrective write is PUT /v3/hooks/{hook_id} with {"is_active": true}, and it should only run after a synthetic health-check request to the destination comes back 200. With DRY_RUN=true, log the intended PUT and skip the network call.

reenable.py
def health_check_ok(destination):
    try:
        r = requests.get(destination, timeout=10)
        return r.status_code == 200
    except requests.RequestException:
        return False

def reenable_hook(hook_id, destination, dry_run, log):
    if not health_check_ok(destination):
        log.warning("Skipping re-enable for hook %s, health check failed.", hook_id)
        return False
    if dry_run:
        log.info("DRY_RUN: would PUT /hooks/%s {'is_active': True}", hook_id)
        return True
    bc_put(f"/hooks/{hook_id}", {"is_active": True})
    log.info("Re-enabled hook %s after passing health check.", hook_id)
    return True
reenable.js
async function healthCheckOk(destination) {
  try {
    const res = await fetch(destination, { method: "GET" });
    return res.status === 200;
  } catch {
    return false;
  }
}

async function reenableHook(hookId, destination, dryRun) {
  if (!(await healthCheckOk(destination))) {
    console.warn(`Skipping re-enable for hook ${hookId}, health check failed.`);
    return false;
  }
  if (dryRun) {
    console.log(`DRY_RUN: would PUT /hooks/${hookId} {"is_active": true}`);
    return true;
  }
  await bcPut(`/hooks/${hookId}`, { is_active: true });
  console.log(`Re-enabled hook ${hookId} after passing health check.`);
  return true;
}
Run it safe

Always start with DRY_RUN=true. Never write to a hook other than flipping is_active back to true, and never do even that without a passing synthetic health check first. There is no API call that lifts a domain blocklist, and trying to force one is not a real operation, it is a no-op at best and a misleading log entry at worst.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, paginates /v3/hooks, evaluates rolling success ratios from a supplied request log, reports every domain at risk and every hook already deactivated, and only ever writes to re-enable a hook once its endpoint passes a live health check, respecting the dry run flag throughout.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

webhook_domain_health.py
"""Detect a BigCommerce webhook domain at risk of being blocklisted.

BigCommerce's webhook dispatcher tracks a rolling success versus failure
ratio per destination domain over a sliding 2-minute window, evaluated only
once at least 100 requests have landed in that window. If the ratio drops
below 90 percent, typically because the receiving endpoint is slow,
returning non-200s, or intermittently down, BigCommerce blocklists the
entire domain for 3 minutes, not just the failing hook. Because the block
is domain scoped, one flaky path (for example /webhooks/orders) can starve
delivery to an unrelated healthy hook (for example /webhooks/inventory) on
the same host. If the instability persists, the same webhook can also hit
the separate 48-hour / 11-retry exhaustion path and get permanently
deactivated (is_active=false).

There is no safe API call to lift a domain blocklist or force a redelivery,
the 3-minute block self-expires and BigCommerce requeues automatically, so
this script never tries. It lists registered hooks with GET /v3/hooks,
correlates them against your own app's request log to compute rolling
success ratios per domain, reports any domain at risk and any hook already
deactivated, and makes exactly one kind of write: re-enabling a hook with
PUT /v3/hooks/{hook_id} and {"is_active": true}, and only after a synthetic
health-check request to the destination returns 200. Guarded by DRY_RUN.

Guide: https://www.allanninal.dev/bigcommerce/webhook-domain-blocklisted-low-success-ratio/
"""
import os
import logging

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("webhook_domain_health")

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"
MIN_SAMPLE = int(os.environ.get("MIN_SAMPLE", "100"))
SUCCESS_THRESHOLD = float(os.environ.get("SUCCESS_THRESHOLD", "0.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_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def evaluate_webhook_health(window_requests: list, min_sample: int = 100, threshold: float = 0.90) -> dict:
    """Pure. No network, no side effects.

    window_requests: list of {"timestamp": float, "domain": str, "status_code": int}
    entries for a single rolling 2-minute window, one per delivery attempt.
    Returns, per domain, {"domain": str, "total": int, "success_ratio": float | None, "at_risk": bool}.
    success_ratio is None (and at_risk False) when total < min_sample, matching
    BigCommerce's rule that the ratio is not evaluated until 100 requests are seen.
    at_risk is True only when total >= min_sample and success_ratio < threshold.
    """
    by_domain = {}
    for entry in window_requests or []:
        domain = entry["domain"]
        bucket = by_domain.setdefault(domain, {"total": 0, "success": 0})
        bucket["total"] += 1
        if 200 <= entry["status_code"] < 300:
            bucket["success"] += 1

    results = {}
    for domain, bucket in by_domain.items():
        total = bucket["total"]
        if total < min_sample:
            results[domain] = {
                "domain": domain,
                "total": total,
                "success_ratio": None,
                "at_risk": False,
            }
            continue
        ratio = bucket["success"] / total
        results[domain] = {
            "domain": domain,
            "total": total,
            "success_ratio": ratio,
            "at_risk": ratio < threshold,
        }
    return results


def list_hooks():
    """Page through every registered hook via GET /v3/hooks."""
    page = 1
    while True:
        payload = bc_get("/hooks", {"page": page, "limit": 50})
        hooks = payload.get("data") or []
        if not hooks:
            return
        for hook in hooks:
            yield hook
        pagination = (payload.get("meta") or {}).get("pagination") or {}
        if page >= (pagination.get("total_pages") or page):
            return
        page += 1


def health_check_ok(destination):
    try:
        r = requests.get(destination, timeout=10)
        return r.status_code == 200
    except requests.RequestException:
        return False


def reenable_hook(hook_id, destination):
    """The only safe write: flip is_active back to True, and only when healthy."""
    if not health_check_ok(destination):
        log.warning("Skipping re-enable for hook %s, health check failed.", hook_id)
        return False
    if DRY_RUN:
        log.info("DRY_RUN: would PUT /hooks/%s {'is_active': True}", hook_id)
        return True
    bc_put(f"/hooks/{hook_id}", {"is_active": True})
    log.info("Re-enabled hook %s after passing health check.", hook_id)
    return True


def fetch_recent_request_log():
    """Placeholder for your app's own request log lookup.

    BigCommerce exposes no delivery-log or success-rate endpoint, so this
    must come from wherever your receiving app records each webhook
    request's timestamp, destination domain, and response status code.
    Replace this with a real query against your logs or metrics store.
    """
    return []


def run():
    window_requests = fetch_recent_request_log()
    health_by_domain = evaluate_webhook_health(window_requests, MIN_SAMPLE, SUCCESS_THRESHOLD)

    at_risk_count = 0
    for result in health_by_domain.values():
        if not result["at_risk"]:
            continue
        at_risk_count += 1
        log.warning(
            "Domain %s at risk of blocklisting. success_ratio=%.3f total=%d",
            result["domain"], result["success_ratio"], result["total"],
        )

    reenabled = 0
    for hook in list_hooks():
        if hook.get("is_active"):
            continue
        hook_id = hook["id"]
        destination = hook.get("destination")
        log.warning(
            "Hook %s is deactivated (is_active=false). destination=%s updated_at=%s",
            hook_id, destination, hook.get("updated_at"),
        )
        if destination and reenable_hook(hook_id, destination):
            reenabled += 1

    log.info(
        "Done. %d domain(s) at risk, %d hook(s) %s.",
        at_risk_count, reenabled, "to re-enable" if DRY_RUN else "re-enabled",
    )


if __name__ == "__main__":
    run()
webhook-domain-health.js
/**
 * Detect a BigCommerce webhook domain at risk of being blocklisted.
 *
 * BigCommerce's webhook dispatcher tracks a rolling success versus failure
 * ratio per destination domain over a sliding 2-minute window, evaluated
 * only once at least 100 requests have landed in that window. If the ratio
 * drops below 90 percent, typically because the receiving endpoint is slow,
 * returning non-200s, or intermittently down, BigCommerce blocklists the
 * entire domain for 3 minutes, not just the failing hook. Because the block
 * is domain scoped, one flaky path (for example /webhooks/orders) can starve
 * delivery to an unrelated healthy hook (for example /webhooks/inventory) on
 * the same host. If the instability persists, the same webhook can also hit
 * the separate 48-hour / 11-retry exhaustion path and get permanently
 * deactivated (is_active=false).
 *
 * There is no safe API call to lift a domain blocklist or force a
 * redelivery, the 3-minute block self-expires and BigCommerce requeues
 * automatically, so this script never tries. It lists registered hooks with
 * GET /v3/hooks, correlates them against your own app's request log to
 * compute rolling success ratios per domain, reports any domain at risk and
 * any hook already deactivated, and makes exactly one kind of write:
 * re-enabling a hook with PUT /v3/hooks/{hook_id} and {"is_active": true},
 * and only after a synthetic health-check request to the destination
 * returns 200. Guarded by DRY_RUN.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/webhook-domain-blocklisted-low-success-ratio/
 */
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 MIN_SAMPLE = Number(process.env.MIN_SAMPLE || 100);
const SUCCESS_THRESHOLD = Number(process.env.SUCCESS_THRESHOLD || 0.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. No network, no side effects.
 *
 * windowRequests: list of {timestamp, domain, status_code} entries for a
 * single rolling 2-minute window, one per delivery attempt.
 * Returns, per domain, {domain, total, success_ratio, at_risk}.
 * success_ratio is null (and at_risk false) when total < minSample, matching
 * BigCommerce's rule that the ratio is not evaluated until 100 requests are
 * seen. at_risk is true only when total >= minSample and success_ratio < threshold.
 */
export function evaluateWebhookHealth(windowRequests, minSample = 100, threshold = 0.90) {
  const byDomain = new Map();
  for (const entry of windowRequests || []) {
    const domain = entry.domain;
    const bucket = byDomain.get(domain) || { total: 0, success: 0 };
    bucket.total += 1;
    if (entry.status_code >= 200 && entry.status_code < 300) bucket.success += 1;
    byDomain.set(domain, bucket);
  }

  const results = {};
  for (const [domain, bucket] of byDomain.entries()) {
    if (bucket.total < minSample) {
      results[domain] = { domain, total: bucket.total, success_ratio: null, at_risk: false };
      continue;
    }
    const ratio = bucket.success / bucket.total;
    results[domain] = { domain, total: bucket.total, success_ratio: ratio, at_risk: ratio < threshold };
  }
  return results;
}

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 bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function* listHooks() {
  let page = 1;
  while (true) {
    const payload = await bcGet("/hooks", { page, limit: 50 });
    const hooks = payload.data || [];
    if (!hooks.length) return;
    for (const hook of hooks) yield hook;
    const pagination = (payload.meta || {}).pagination || {};
    if (page >= (pagination.total_pages || page)) return;
    page += 1;
  }
}

async function healthCheckOk(destination) {
  try {
    const res = await fetch(destination, { method: "GET" });
    return res.status === 200;
  } catch {
    return false;
  }
}

async function reenableHook(hookId, destination) {
  if (!(await healthCheckOk(destination))) {
    console.warn(`Skipping re-enable for hook ${hookId}, health check failed.`);
    return false;
  }
  if (DRY_RUN) {
    console.log(`DRY_RUN: would PUT /hooks/${hookId} {"is_active": true}`);
    return true;
  }
  await bcPut(`/hooks/${hookId}`, { is_active: true });
  console.log(`Re-enabled hook ${hookId} after passing health check.`);
  return true;
}

/**
 * Placeholder for your app's own request log lookup. BigCommerce exposes no
 * delivery-log or success-rate endpoint, so this must come from wherever
 * your receiving app records each webhook request's timestamp, destination
 * domain, and response status code. Replace with a real query.
 */
async function fetchRecentRequestLog() {
  return [];
}

export async function run() {
  const windowRequests = await fetchRecentRequestLog();
  const healthByDomain = evaluateWebhookHealth(windowRequests, MIN_SAMPLE, SUCCESS_THRESHOLD);

  let atRiskCount = 0;
  for (const result of Object.values(healthByDomain)) {
    if (!result.at_risk) continue;
    atRiskCount += 1;
    console.warn(
      `Domain ${result.domain} at risk of blocklisting. ` +
      `success_ratio=${result.success_ratio.toFixed(3)} total=${result.total}`
    );
  }

  let reenabled = 0;
  for await (const hook of listHooks()) {
    if (hook.is_active) continue;
    const hookId = hook.id;
    const destination = hook.destination;
    console.warn(
      `Hook ${hookId} is deactivated (is_active=false). destination=${destination} updated_at=${hook.updated_at}`
    );
    if (destination && (await reenableHook(hookId, destination))) {
      reenabled += 1;
    }
  }

  console.log(
    `Done. ${atRiskCount} domain(s) at risk, ${reenabled} hook(s) ${DRY_RUN ? "to re-enable" : "re-enabled"}.`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule worth testing is the rolling-window evaluation, because it decides whether a domain gets flagged as at risk. Because evaluate_webhook_health takes only a plain list of dictionaries and returns a plain dictionary, the test needs no network and no BigCommerce store. It just feeds in plain request-log entries and checks the answer.

test_webhook_health.py
from webhook_domain_health import evaluate_webhook_health


def make_requests(domain, total, failures):
    entries = []
    for i in range(total):
        status = 500 if i < failures else 200
        entries.append({"timestamp": float(i), "domain": domain, "status_code": status})
    return entries


def test_domain_below_sample_size_is_not_evaluated():
    requests_ = make_requests("shop-a.example.com", 40, 40)
    result = evaluate_webhook_health(requests_)
    entry = result["shop-a.example.com"]
    assert entry["total"] == 40
    assert entry["success_ratio"] is None
    assert entry["at_risk"] is False


def test_domain_at_or_above_sample_with_low_ratio_is_at_risk():
    requests_ = make_requests("shop-b.example.com", 100, 15)
    result = evaluate_webhook_health(requests_)
    entry = result["shop-b.example.com"]
    assert entry["total"] == 100
    assert entry["success_ratio"] == 0.85
    assert entry["at_risk"] is True


def test_domain_at_sample_with_healthy_ratio_is_not_at_risk():
    requests_ = make_requests("shop-c.example.com", 120, 5)
    result = evaluate_webhook_health(requests_)
    entry = result["shop-c.example.com"]
    assert entry["total"] == 120
    assert round(entry["success_ratio"], 4) == round((120 - 5) / 120, 4)
    assert entry["at_risk"] is False


def test_ratio_exactly_at_threshold_is_not_at_risk():
    requests_ = make_requests("shop-d.example.com", 100, 10)
    result = evaluate_webhook_health(requests_)
    entry = result["shop-d.example.com"]
    assert entry["success_ratio"] == 0.90
    assert entry["at_risk"] is False


def test_multiple_domains_are_evaluated_independently():
    healthy = make_requests("healthy.example.com", 100, 2)
    flaky = make_requests("flaky.example.com", 150, 40)
    result = evaluate_webhook_health(healthy + flaky)
    assert result["healthy.example.com"]["at_risk"] is False
    assert result["flaky.example.com"]["at_risk"] is True


def test_empty_input_returns_empty_dict():
    assert evaluate_webhook_health([]) == {}
webhook-domain-health.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateWebhookHealth } from "./webhook-domain-health.js";

function makeRequests(domain, total, failures) {
  const entries = [];
  for (let i = 0; i < total; i++) {
    const status = i < failures ? 500 : 200;
    entries.push({ timestamp: i, domain, status_code: status });
  }
  return entries;
}

test("domain below sample size is not evaluated", () => {
  const result = evaluateWebhookHealth(makeRequests("shop-a.example.com", 40, 40));
  const entry = result["shop-a.example.com"];
  assert.equal(entry.total, 40);
  assert.equal(entry.success_ratio, null);
  assert.equal(entry.at_risk, false);
});

test("domain at or above sample with low ratio is at risk", () => {
  const result = evaluateWebhookHealth(makeRequests("shop-b.example.com", 100, 15));
  const entry = result["shop-b.example.com"];
  assert.equal(entry.total, 100);
  assert.equal(entry.success_ratio, 0.85);
  assert.equal(entry.at_risk, true);
});

test("domain at sample with healthy ratio is not at risk", () => {
  const result = evaluateWebhookHealth(makeRequests("shop-c.example.com", 120, 5));
  const entry = result["shop-c.example.com"];
  assert.equal(entry.total, 120);
  assert.equal(Math.round(entry.success_ratio * 10000) / 10000, Math.round(((120 - 5) / 120) * 10000) / 10000);
  assert.equal(entry.at_risk, false);
});

test("ratio exactly at threshold is not at risk", () => {
  const result = evaluateWebhookHealth(makeRequests("shop-d.example.com", 100, 10));
  const entry = result["shop-d.example.com"];
  assert.equal(entry.success_ratio, 0.90);
  assert.equal(entry.at_risk, false);
});

test("multiple domains are evaluated independently", () => {
  const healthy = makeRequests("healthy.example.com", 100, 2);
  const flaky = makeRequests("flaky.example.com", 150, 40);
  const result = evaluateWebhookHealth([...healthy, ...flaky]);
  assert.equal(result["healthy.example.com"].at_risk, false);
  assert.equal(result["flaky.example.com"].at_risk, true);
});

test("empty input returns empty object", () => {
  assert.deepEqual(evaluateWebhookHealth([]), {});
});

Case studies

Shared domain, one bad path

The store whose inventory hook went quiet during an orders incident

A merchant's order-processing service started timing out during a deploy, and the team assumed only the orders webhook would be affected. Instead their inventory sync, on a completely separate path but the same domain, silently stopped receiving deliveries for a few minutes at a time throughout the incident, and nobody connected the two until they compared timestamps.

Once they started correlating GET /v3/hooks against their own access logs bucketed into rolling 2-minute windows, the pattern was obvious: every time the orders path's failures pushed the domain-wide ratio under 90 percent, the inventory path went quiet too. The fix was not code, it was recognizing the domain-scoped nature of the block and fixing the actual slow endpoint instead of chasing a phantom inventory bug.

Auto-deactivated after the fact

The hook that outlived its 3-minute blocks until it did not

A smaller integration had an endpoint that flaked out just often enough to trip the 3-minute domain block a few times a week, always recovering once the block expired. Nobody was watching it, so nobody noticed the underlying instability never actually got fixed, it just kept happening to clear before anyone looked.

Eventually the same flakiness crossed the separate 48-hour and 11-retry exhaustion threshold, and BigCommerce flipped is_active to false on that specific hook. A scan against updated_at and is_active caught it within the hour. After confirming the endpoint held up under a synthetic health check, the team ran the script with DRY_RUN=false to flip it back on, this time with monitoring in place.

What good looks like

A domain trending toward the 90 percent floor gets flagged before it ever gets blocklisted, with the exact ratio and window so whoever owns the receiving endpoint can look at the right minutes instead of guessing. If a hook does get auto-deactivated, it is caught quickly and only ever re-enabled once a live health check confirms the destination is actually fixed, never blindly. And nobody wastes time trying to call an API to lift a 3-minute block that was already expiring on its own.

FAQ

Why did BigCommerce stop delivering webhooks to a domain that was working fine an hour ago?

BigCommerce's webhook dispatcher calculates a rolling success versus failure ratio per destination domain over a sliding 2-minute window, but only once at least 100 requests have been sent to that domain in the window. If the ratio drops below 90 percent, typically because the receiving endpoint is slow, returning non-200 responses, or intermittently down, BigCommerce blocklists the entire domain for 3 minutes. Because the block is domain-scoped, every hook on that host stops receiving deliveries, not just the one that was failing.

Can one broken webhook path take down a healthy one on the same domain?

Yes. Blocking happens per destination domain, not per registered hook or path. If /webhooks/orders on a host is flaky enough to drag the domain-wide success ratio below 90 percent, deliveries to an unrelated and perfectly healthy /webhooks/inventory endpoint on that same host are starved for the 3-minute block too, since BigCommerce is not distinguishing between paths, only the domain.

Is there an API call to lift the blocklist or force BigCommerce to redeliver the missed events?

No. The 3-minute domain blocklist self-expires and BigCommerce automatically requeues the affected events once it does, so there is no safe endpoint to call to lift it early or force a redelivery. The only corrective API write available is re-enabling a hook that got separately auto-deactivated (is_active set to false after repeated failures) with PUT /v3/hooks/{hook_id} and {"is_active": true}, and only after confirming the destination is healthy again.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: webhooks overview, delivery, and retry behavior. developer.bigcommerce.com webhooks overview
  2. BigCommerce Support: how to avoid webhooks deactivation, it happens periodically. support.bigcommerce.com how to avoid webhooks deactivation
  3. Hookdeck: guide to BigCommerce webhooks, features and best practices. hookdeck.com BigCommerce webhooks guide

On the solution:

  1. BigCommerce API Reference: the Webhooks v3 endpoints, including GET and PUT /v3/hooks. docs.bigcommerce.com webhooks v3
  2. BigCommerce Docs: webhooks overview and pagination via meta.pagination. docs.bigcommerce.com webhooks overview
  3. BigCommerce Developer Center: manage webhooks, including re-enabling a single hook. developer.bigcommerce.com manage webhooks

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 save you from chasing a phantom bug?

If this helped you spot a domain-wide block before it hit, or explained why an unrelated hook went quiet, 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