Reconciler Webhooks

Duplicate webhook deliveries run twice

An order gets fulfilled twice. A confirmation email goes out twice. A downstream system double counts a sale. You check the logs and the same BigCommerce webhook payload really did arrive more than once. Nothing is broken. BigCommerce's webhook dispatcher was designed around retrying until it gets a clean response, not around delivering each event exactly one time, and a couple of ordinary store behaviors can pile more copies on top of that. Here is why it happens and a small, idempotent layer that stops your handler from acting on the same event twice.

Python and Node.js BigCommerce Webhooks v3 Safe by default (idempotent skip)
The short answer

BigCommerce's webhook dispatcher does not guarantee exactly-once delivery. If your endpoint is slow or returns a non-2xx response, it retries the identical payload for up to about 48 hours, up to 11 attempts, before setting the hook's is_active to false. Rapid back-to-back admin edits and duplicate scope+destination hook registrations can also produce more than one delivery for what is really one logical event. Since the payload carries no dedicated delivery id, only scope, store_id, data, hash, created_at, and producer, compute your own delivery id from those fields, record it before doing any work, and skip the request if you have already seen it. Full code, tests, and a dry run guard are below.

The problem in plain words

Webhooks feel like a single, reliable message: something happened, BigCommerce told you once, you reacted once. In practice BigCommerce's dispatcher is built to keep trying until it is satisfied your endpoint received the event, and it decides that only from your HTTP response. If your handler is slow, throws a timeout, or answers with anything outside the 2xx range, BigCommerce assumes the delivery failed and sends the exact same payload again later. It keeps doing that for roughly 48 hours and up to 11 attempts before it gives up and flips the hook's is_active flag to false.

That retry loop alone is enough to make a handler run twice, but two other ordinary things add to it. Rapid, back-to-back admin actions on the same resource, like toggling a product's purchasability twice or updating an order's status twice within a second, can generate multiple distinct-but-near-identical events only a couple of seconds apart. And if app install or setup code ever runs more than once, it is easy to end up with two active hooks registered on the same scope and destination, in which case BigCommerce fires the same logical event once per matching hook, which looks exactly like a duplicate delivery from the outside.

Event occurs order or product change BigCommerce POSTs webhook payload slow or non-2xx BigCommerce retries up to 11 tries, ~48h Handler runs first time order fulfilled, email sent Same payload resent identical hash and data no dedupe Handler runs again: business logic doubles
BigCommerce is not being wrong. It is retrying an event it believes failed, and a naive handler that reacts to every POST processes the same business event twice.

Why it happens

Nothing in the delivery mechanism promises at-most-once processing. A few ordinary ways stores end up with duplicates:

This is a common source of confusion for teams new to BigCommerce webhooks. They read "webhook fired twice" and assume their endpoint is buggy, when the retry behavior and the lack of a delivery id are documented and expected. See the citations at the end for the exact references and support threads.

The key insight

You cannot ask BigCommerce to stop retrying, and you should not want it to, since retries are what keep a slow blip from silently losing an event. The safe pattern is not "make BigCommerce deliver once." It is "make your own handler indifferent to receiving the same event more than once." We do that with an app-side idempotency check keyed on a delivery id computed from the payload, so every resend is a safe no-op instead of a repeated side effect.

The fix, as a flow

We do not change anything about how BigCommerce sends webhooks. We add a small check in front of the handler that computes a delivery id from the payload, looks it up in a table with a unique constraint, and only lets new deliveries through to the business logic. We also check for duplicate active hook registrations on the same scope and destination, since that is a separate cause of the same symptom.

Inbound POST hash, created_at, scope Compute delivery id sha256 of key fields Check hooks + seen ids GET /v3/hooks?scope= Already seen or fan-out? no yes, skip Return 200 no reprocessing
Only a delivery id nobody has seen before, on a scope with exactly one active hook, reaches the business logic. Everything else is a safe, logged no-op.

Build it step by step

1

Get a BigCommerce API access token

Create an API account in your BigCommerce control panel under Settings, API, and generate a token with the Webhooks read scope, plus write if you want the script to deactivate duplicate hooks. Keep the store hash and the token in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the BigCommerce REST API

Every call carries your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper sends the request, raises on a non-2xx response, and returns the parsed body. We reuse this helper to list hooks and, later, to deactivate duplicates.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    return r.json() if r.content else None
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}
3

Check for duplicate hook registrations

Call GET /v3/hooks?scope={scope} and count the active hooks that share the same scope and destination. More than one active hook on the same pair means every event is delivered once per hook, which looks identical to a duplicate delivery from the consumer's side.

step3.py
def active_hooks_for_scope(scope):
    resp = bc("GET", f"/v3/hooks?scope={scope}&limit=250")
    return [h for h in (resp or {}).get("data", []) if h.get("is_active")]

def duplicate_fanout_groups(scope):
    """Group active hooks for a scope by destination, keep groups with more than one."""
    by_destination = {}
    for hook in active_hooks_for_scope(scope):
        by_destination.setdefault(hook["destination"], []).append(hook)
    return {dest: hooks for dest, hooks in by_destination.items() if len(hooks) > 1}
step3.js
async function activeHooksForScope(scope) {
  const resp = await bc("GET", `/v3/hooks?scope=${scope}&limit=250`);
  return (resp?.data || []).filter((h) => h.is_active);
}

async function duplicateFanoutGroups(scope) {
  const byDestination = new Map();
  for (const hook of await activeHooksForScope(scope)) {
    const list = byDestination.get(hook.destination) || [];
    list.push(hook);
    byDestination.set(hook.destination, list);
  }
  const result = {};
  for (const [dest, hooks] of byDestination) {
    if (hooks.length > 1) result[dest] = hooks;
  }
  return result;
}
4

Decide, with one pure function

Keep the delivery decision in its own function that takes the payload, the set of delivery ids already processed, and the count of active hooks for that scope, and returns what to do. It computes the delivery id by hashing hash, created_at, scope, and producer together, since the payload has no dedicated delivery id of its own. A pure function like this needs no network to test, which we do later.

decide.py
import hashlib

def classify_webhook_delivery(payload, seen_delivery_ids, active_hooks_for_scope):
    raw = f"{payload['hash']}|{payload['created_at']}|{payload['scope']}|{payload['producer']}"
    delivery_id = hashlib.sha256(raw.encode("utf-8")).hexdigest()
    if active_hooks_for_scope > 1:
        return {"deliveryId": delivery_id, "action": "flag_fanout"}
    if delivery_id in seen_delivery_ids:
        return {"deliveryId": delivery_id, "action": "skip_duplicate"}
    return {"deliveryId": delivery_id, "action": "process"}
decide.js
import { createHash } from "node:crypto";

export function classifyWebhookDelivery(payload, seenDeliveryIds, activeHooksForScope) {
  const raw = `${payload.hash}|${payload.created_at}|${payload.scope}|${payload.producer}`;
  const deliveryId = createHash("sha256").update(raw).digest("hex");
  if (activeHooksForScope > 1) return { deliveryId, action: "flag_fanout" };
  if (seenDeliveryIds.has(deliveryId)) return { deliveryId, action: "skip_duplicate" };
  return { deliveryId, action: "process" };
}
5

Record the delivery before doing any business logic

On every inbound POST, insert the computed delivery id into a webhook_deliveries table with a unique constraint before running any business logic. If the insert violates uniqueness, this is a resend, so return HTTP 200 immediately without reprocessing. Returning 200 matters as much as the skip itself, since that is what tells BigCommerce the delivery succeeded and stops it from retrying further.

apply.py
def handle_delivery(payload, seen_delivery_ids, active_hooks_for_scope, process_fn):
    """Returns True if the payload was processed, False if it was skipped or flagged."""
    result = classify_webhook_delivery(payload, seen_delivery_ids, active_hooks_for_scope)
    if result["action"] != "process":
        log.info("Delivery %s %s, not reprocessing.", result["deliveryId"][:12], result["action"])
        return False
    seen_delivery_ids.add(result["deliveryId"])
    process_fn(payload)
    return True
apply.js
function handleDelivery(payload, seenDeliveryIds, activeHooksForScope, processFn) {
  const result = classifyWebhookDelivery(payload, seenDeliveryIds, activeHooksForScope);
  if (result.action !== "process") {
    console.log(`Delivery ${result.deliveryId.slice(0, 12)} ${result.action}, not reprocessing.`);
    return false;
  }
  seenDeliveryIds.add(result.deliveryId);
  processFn(payload);
  return true;
}
6

Wire it together with a dry run guard

The scan job pulls the scopes you care about, checks each for duplicate active hook registrations, and only deactivates the extra hooks when DRY_RUN is off. Leave DRY_RUN=true on the first runs so the script only logs which hook ids it would deactivate. Read the log, confirm the surviving hook is the right one, then switch it off. The idempotent skip itself is app-side and always on, since it needs no confirmation to be safe.

Run it safe

Always start with DRY_RUN=true for the fan-out cleanup. Never deactivate or delete a hook without first confirming it is truly a duplicate of another active scope+destination pair, and always leave the oldest surviving hook's is_active untouched.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and keeps the idempotent skip as an always-on, app-side check that never calls the BigCommerce API.

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

dedupe_webhook_deliveries.py
"""Classify BigCommerce webhook deliveries so a handler never processes a resend twice.

BigCommerce's dispatcher does not guarantee exactly-once delivery: a slow or non-2xx
endpoint gets the identical payload retried for up to about 48 hours, up to 11 attempts,
before the hook's is_active is set to false. Duplicate active hook registrations for the
same scope and destination also fan out one logical event into several deliveries. This
computes a delivery id from hash, created_at, scope, and producer (the payload has no
dedicated delivery id), skips anything already seen, and flags scopes where more than one
active hook would explain the duplicates. Also finds and, when confirmed, deactivates the
extra hooks. Run on a schedule for the hook scan. Safe to run again and again.
"""
import os
import hashlib
import logging
import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
WATCHED_SCOPES = [s.strip() for s in os.environ.get("WATCHED_SCOPES", "store/order/updated").split(",") if s.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    return r.json() if r.content else None


def classify_webhook_delivery(payload, seen_delivery_ids, active_hooks_for_scope):
    """Pure decision function. No network calls.

    payload: {"scope": str, "hash": str, "created_at": int, "producer": str}
    seen_delivery_ids: a set-like of delivery ids already processed
    active_hooks_for_scope: count of active hooks sharing this scope + destination
    """
    raw = f"{payload['hash']}|{payload['created_at']}|{payload['scope']}|{payload['producer']}"
    delivery_id = hashlib.sha256(raw.encode("utf-8")).hexdigest()
    if active_hooks_for_scope > 1:
        return {"deliveryId": delivery_id, "action": "flag_fanout"}
    if delivery_id in seen_delivery_ids:
        return {"deliveryId": delivery_id, "action": "skip_duplicate"}
    return {"deliveryId": delivery_id, "action": "process"}


def handle_delivery(payload, seen_delivery_ids, active_hooks_for_scope, process_fn):
    """Returns True if the payload was processed, False if it was skipped or flagged."""
    result = classify_webhook_delivery(payload, seen_delivery_ids, active_hooks_for_scope)
    if result["action"] != "process":
        log.info("Delivery %s %s, not reprocessing.", result["deliveryId"][:12], result["action"])
        return False
    seen_delivery_ids.add(result["deliveryId"])
    process_fn(payload)
    return True


def active_hooks_for_scope(scope):
    resp = bc("GET", f"/v3/hooks?scope={scope}&limit=250")
    return [h for h in (resp or {}).get("data", []) if h.get("is_active")]


def duplicate_fanout_groups(scope):
    """Group active hooks for a scope by destination, keep groups with more than one."""
    by_destination = {}
    for hook in active_hooks_for_scope(scope):
        by_destination.setdefault(hook["destination"], []).append(hook)
    return {dest: hooks for dest, hooks in by_destination.items() if len(hooks) > 1}


def deactivate_hook(hook_id):
    return bc("PUT", f"/v3/hooks/{hook_id}", json={"is_active": False})


def run():
    flagged = 0
    for scope in WATCHED_SCOPES:
        groups = duplicate_fanout_groups(scope)
        for destination, hooks in groups.items():
            hooks_sorted = sorted(hooks, key=lambda h: h.get("created_at", 0))
            keep, extras = hooks_sorted[0], hooks_sorted[1:]
            log.warning(
                "Scope %s destination %s has %d active hooks. Keeping id=%s, %s: %s",
                scope, destination, len(hooks_sorted), keep["id"],
                "would deactivate" if DRY_RUN else "deactivating",
                [h["id"] for h in extras],
            )
            if not DRY_RUN:
                for hook in extras:
                    deactivate_hook(hook["id"])
            flagged += len(extras)
    log.info("Done. %d duplicate hook(s) %s.", flagged, "to deactivate" if DRY_RUN else "deactivated")


if __name__ == "__main__":
    run()
dedupe-webhook-deliveries.js
/**
 * Classify BigCommerce webhook deliveries so a handler never processes a resend twice.
 *
 * BigCommerce's dispatcher does not guarantee exactly-once delivery: a slow or non-2xx
 * endpoint gets the identical payload retried for up to about 48 hours, up to 11 attempts,
 * before the hook's is_active is set to false. Duplicate active hook registrations for the
 * same scope and destination also fan out one logical event into several deliveries. This
 * computes a delivery id from hash, created_at, scope, and producer (the payload has no
 * dedicated delivery id), skips anything already seen, and flags scopes where more than one
 * active hook would explain the duplicates. Also finds and, when confirmed, deactivates the
 * extra hooks. Run on a schedule for the hook scan. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/duplicate-webhook-deliveries-run-twice/
 */
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const WATCHED_SCOPES = (process.env.WATCHED_SCOPES || "store/order/updated")
  .split(",").map((s) => s.trim()).filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

/**
 * Pure decision function. No network calls.
 * payload: { scope: string, hash: string, created_at: number, producer: string }
 * seenDeliveryIds: a ReadonlySet of delivery ids already processed
 * activeHooksForScope: count of active hooks sharing this scope + destination
 */
export function classifyWebhookDelivery(payload, seenDeliveryIds, activeHooksForScope) {
  const raw = `${payload.hash}|${payload.created_at}|${payload.scope}|${payload.producer}`;
  const deliveryId = createHash("sha256").update(raw).digest("hex");
  if (activeHooksForScope > 1) return { deliveryId, action: "flag_fanout" };
  if (seenDeliveryIds.has(deliveryId)) return { deliveryId, action: "skip_duplicate" };
  return { deliveryId, action: "process" };
}

export function handleDelivery(payload, seenDeliveryIds, activeHooksForScope, processFn) {
  const result = classifyWebhookDelivery(payload, seenDeliveryIds, activeHooksForScope);
  if (result.action !== "process") {
    console.log(`Delivery ${result.deliveryId.slice(0, 12)} ${result.action}, not reprocessing.`);
    return false;
  }
  seenDeliveryIds.add(result.deliveryId);
  processFn(payload);
  return true;
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}

async function activeHooksForScope(scope) {
  const resp = await bc("GET", `/v3/hooks?scope=${scope}&limit=250`);
  return (resp?.data || []).filter((h) => h.is_active);
}

async function duplicateFanoutGroups(scope) {
  const byDestination = new Map();
  for (const hook of await activeHooksForScope(scope)) {
    const list = byDestination.get(hook.destination) || [];
    list.push(hook);
    byDestination.set(hook.destination, list);
  }
  const result = {};
  for (const [dest, hooks] of byDestination) {
    if (hooks.length > 1) result[dest] = hooks;
  }
  return result;
}

async function deactivateHook(hookId) {
  return bc("PUT", `/v3/hooks/${hookId}`, { is_active: false });
}

export async function run() {
  let flagged = 0;
  for (const scope of WATCHED_SCOPES) {
    const groups = await duplicateFanoutGroups(scope);
    for (const [destination, hooks] of Object.entries(groups)) {
      const sorted = [...hooks].sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
      const [keep, ...extras] = sorted;
      console.warn(
        `Scope ${scope} destination ${destination} has ${sorted.length} active hooks. Keeping id=${keep.id}, ${DRY_RUN ? "would deactivate" : "deactivating"}: ${extras.map((h) => h.id)}`
      );
      if (!DRY_RUN) {
        for (const hook of extras) await deactivateHook(hook.id);
      }
      flagged += extras.length;
    }
  }
  console.log(`Done. ${flagged} duplicate hook(s) ${DRY_RUN ? "to deactivate" : "deactivated"}.`);
}

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 handler runs business logic again. Because we kept classify_webhook_delivery pure, no I/O at all, the test needs no network and no BigCommerce store. It just feeds in fixture payloads and checks the answer.

test_duplicate_delivery.py
from dedupe_webhook_deliveries import classify_webhook_delivery


def payload(**over):
    base = {"scope": "store/order/updated", "hash": "abc123", "created_at": 1000, "producer": "stores/abc"}
    base.update(over)
    return base


def test_new_delivery_is_processed():
    result = classify_webhook_delivery(payload(), set(), 1)
    assert result["action"] == "process"


def test_same_hash_created_at_scope_producer_is_duplicate():
    first = classify_webhook_delivery(payload(), set(), 1)
    seen = {first["deliveryId"]}
    second = classify_webhook_delivery(payload(), seen, 1)
    assert second["deliveryId"] == first["deliveryId"]
    assert second["action"] == "skip_duplicate"


def test_different_created_at_is_a_new_event():
    # simulates the ~2s duplicate-fire case from rapid back-to-back admin edits
    first = classify_webhook_delivery(payload(created_at=1000), set(), 1)
    seen = {first["deliveryId"]}
    second = classify_webhook_delivery(payload(created_at=1002), seen, 1)
    assert second["deliveryId"] != first["deliveryId"]
    assert second["action"] == "process"


def test_fanout_flagged_even_if_never_seen_before():
    # two active hooks on the same scope + destination fan out one event into two deliveries
    result = classify_webhook_delivery(payload(), set(), 2)
    assert result["action"] == "flag_fanout"


def test_fanout_takes_priority_over_duplicate_check():
    first = classify_webhook_delivery(payload(), set(), 1)
    seen = {first["deliveryId"]}
    second = classify_webhook_delivery(payload(), seen, 2)
    assert second["action"] == "flag_fanout"
duplicate-delivery.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyWebhookDelivery } from "./dedupe-webhook-deliveries.js";

const payload = (over = {}) => ({
  scope: "store/order/updated", hash: "abc123", created_at: 1000, producer: "stores/abc", ...over,
});

test("new delivery is processed", () => {
  const result = classifyWebhookDelivery(payload(), new Set(), 1);
  assert.equal(result.action, "process");
});

test("same hash, created_at, scope, producer is a duplicate", () => {
  const first = classifyWebhookDelivery(payload(), new Set(), 1);
  const seen = new Set([first.deliveryId]);
  const second = classifyWebhookDelivery(payload(), seen, 1);
  assert.equal(second.deliveryId, first.deliveryId);
  assert.equal(second.action, "skip_duplicate");
});

test("different created_at is a new event", () => {
  // simulates the ~2s duplicate-fire case from rapid back-to-back admin edits
  const first = classifyWebhookDelivery(payload({ created_at: 1000 }), new Set(), 1);
  const seen = new Set([first.deliveryId]);
  const second = classifyWebhookDelivery(payload({ created_at: 1002 }), seen, 1);
  assert.notEqual(second.deliveryId, first.deliveryId);
  assert.equal(second.action, "process");
});

test("fanout flagged even if never seen before", () => {
  // two active hooks on the same scope + destination fan out one event into two deliveries
  const result = classifyWebhookDelivery(payload(), new Set(), 2);
  assert.equal(result.action, "flag_fanout");
});

test("fanout takes priority over duplicate check", () => {
  const first = classifyWebhookDelivery(payload(), new Set(), 1);
  const seen = new Set([first.deliveryId]);
  const second = classifyWebhookDelivery(payload(), seen, 2);
  assert.equal(second.action, "flag_fanout");
});

Case studies

Slow endpoint

The fulfillment webhook that doubled every shipment

A merchant's fulfillment integration called a slow third-party warehouse API inside the webhook handler itself, and the whole request sometimes took longer than BigCommerce's timeout allowed. BigCommerce treated the slow response as a failure and retried the identical store/order/updated event, and the handler, having no memory of what it had already done, created a second fulfillment request for the same order.

Adding the delivery id check meant the retry was recognized immediately and skipped with a 200, so the warehouse never saw the same order twice, and the team separately moved the slow warehouse call off the request path into a queue.

Duplicate hook registration

The app reinstall that quietly doubled every event

A custom app's install script registered a webhook on store/order/updated every time it ran, and after a redeploy accidentally reran the setup step, the store ended up with two active hooks on the exact same destination. From that point on, every single order update fired the handler twice, and it looked exactly like BigCommerce was misbehaving.

Running the hook scan against GET /v3/hooks?scope=store/order/updated immediately surfaced two active hooks sharing a destination. The team confirmed which one was original, deactivated the duplicate with the DRY_RUN guard off, and the doubled events stopped without touching the handler at all.

What good looks like

After this is in place, a retried delivery or a duplicate hook registration is a logged no-op instead of a repeated side effect. BigCommerce still gets its 200 and stops retrying, real distinct events a couple of seconds apart still get processed because they hash to a different delivery id, and any fan-out from duplicate hooks gets caught and cleaned up on its own schedule instead of quietly doubling every event forever.

FAQ

Why does BigCommerce send the same webhook event more than once?

BigCommerce does not guarantee exactly-once delivery. If your endpoint is slow, times out, or returns a non-2xx response, BigCommerce retries the same event for up to about 48 hours, up to 11 attempts, before finally deactivating the hook. Rapid back-to-back admin edits on the same resource and duplicate hook registrations for the same scope and destination can also cause the same logical event to arrive more than once.

How do I tell a resend apart from a genuinely new event?

The webhook payload has no dedicated delivery id field, only scope, store_id, data, hash, created_at, and producer. Compute your own delivery id by hashing hash together with created_at, scope, and producer, and store it before processing. If that id has already been seen, it is a resend from BigCommerce's retry loop or from a duplicate hook, not a new event.

Is it safe to just ignore every repeated webhook POST?

Yes, as long as you key the skip on a computed delivery id rather than on scope alone, and you still return HTTP 200 for the skipped request. Returning 200 tells BigCommerce the delivery succeeded so it stops retrying, and skipping only exact repeats of the same hash, created_at, scope, and producer never causes you to miss a real, distinct update.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Webhooks Overview. developer.bigcommerce.com/docs/integrations/webhooks
  2. BigCommerce Support: store/order/updated webhook sending multiple times within the same second and same status. support.bigcommerce.com storeorderupdated webhook sending multiple times
  3. BigCommerce Support: webhook called twice. support.bigcommerce.com webhook called twice

On the solution:

  1. BigCommerce API Reference: Webhooks v3. docs.bigcommerce.com developer api-reference webhooks
  2. BigCommerce Developer Center: Webhooks Admin. developer.bigcommerce.com/docs/webhooks/webhooks/webhooks-admin
  3. BigCommerce Support: how to avoid webhooks deactivation, it happens periodically. support.bigcommerce.com how to avoid webhooks deactivation

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, webhooks, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this stop a doubled webhook?

If this saved you a duplicated fulfillment or a confusing double email, 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