Diagnostic Webhooks

BigCommerce webhook fires duplicate events within the same second

Your endpoint logs two store/order/statusUpdated calls for the same order, same status, a fraction of a second apart. Nothing in your store looks broken, the order's real status is correct, but your handler ran twice. BigCommerce's webhook service is at-least-once, not exactly-once, and a single admin action can legitimately trigger more than one subscription at once. Here is why that happens and a small idempotency check that drops the duplicate without ever touching order state.

Python and Node.js BigCommerce v3 Hooks API Safe by default (dry run)
Close-up of computer server rack components
Photo by Đào Hiếu on Unsplash
The short answer

BigCommerce's webhook service guarantees at-least-once delivery, not exactly-once. If your endpoint is slow, times out, or its 200 OK response is lost in transit, the retry mechanism re-sends the identical event. Separately, a single order status change can legitimately fire more than one webhook subscription in the same second, each with its own created_at and a hash that is not guaranteed stable, so hash alone cannot prove a duplicate. Keep a short-lived idempotency store keyed on (store_id, resource_id, new_status_id) with created_at rounded into a one to two second bucket, drop anything that matches an entry already seen, and confirm the order's real state with GET /v2/orders/{order_id} when you need certainty. Also check GET /v3/hooks for a duplicate hook registration pointed at the same destination, a common cause of doubled delivery. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce webhooks are not a guarantee that your endpoint receives each logical event exactly once. The delivery contract is at-least-once: if your handler is slow, if the connection times out mid-response, or if the 200 OK your endpoint sent back gets lost on the way to BigCommerce, the delivery is treated as failed and the same event is retried. From your side, that shows up as the same store/order/statusUpdated payload for the same order id and the same status_id arriving twice, sometimes less than a second apart.

There is a second, entirely legitimate cause that looks identical in your logs. A single action in the admin or through the API, an order status change plus its associated order-updated action, for instance, can trigger more than one distinct webhook subscription at once. Two different, valid events for the same order can land in your endpoint within the same second, each with its own created_at and its own hash. Because that hash is not guaranteed to be stable or independently verifiable, you cannot lean on it alone to tell a true retry-duplicate apart from two separate, both-real events. Either way, the result your handler sees is the same: near-identical payloads, same order, same status, landing almost simultaneously.

Order status changes once Webhook POST #1 200 OK lost in transit Retry fires Webhook POST #2 same order, same status Handler runs twice Both requests land inside the same one second window
BigCommerce's at-least-once contract means a slow or lost acknowledgement gets retried, sending the identical event again within the same second.

Why it happens

This is a recurring theme in BigCommerce support threads: merchants see the exact same order status update delivered two or more times in the same second, with the order itself only having changed once. See the citations at the end for the exact threads and docs.

The key insight

The webhook payload alone cannot tell you whether an event is a duplicate. What can is a short idempotency window keyed on the fields that actually identify "this state transition happened," namely resource_id and new_status_id, with created_at rounded into a one to two second bucket. If you have already processed that key inside the window, drop the new delivery. If you need to be certain about the order's current state rather than trust the payload, call GET /v2/orders/{order_id} and compare status_id and date_modified against what you last recorded. This is a redundant-delivery problem, not an order-data problem, so the fix never touches the order itself.

The fix, as a flow

We do not change how BigCommerce sends webhooks and we do not touch order state. We add an idempotency check in front of the handler, and a separate, guarded check for duplicate hook registrations that would otherwise double every delivery.

Inbound POST store/order/statusUpdated Read payload resource_id, new_status_id Idempotency store key, last seen created_at Within window of last seen? no yes, drop as duplicate Process event record key, run handler
A new key inside the window is processed and recorded. A repeat key inside the window is dropped as a duplicate before your handler ever runs.

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 Orders (read) scope to confirm order state, and Webhooks (modify) if you want the script to be able to clean up a duplicate hook registration. 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 DEDUPE_WINDOW_SECONDS="2"
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 DEDUPE_WINDOW_SECONDS="2"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the REST Management API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/ with the token in the X-Auth-Token header. A small helper handles GET, and we reuse it against the V2 order endpoint and the V3 hooks endpoint.

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}"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []

def bc_delete(path):
    r = requests.delete(f"{API_BASE}{path}", headers=HEADERS, timeout=30)
    r.raise_for_status()
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}`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

async function bcDelete(path) {
  const res = await fetch(`${API_BASE}${path}`, { method: "DELETE", headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
}
3

Decide, with one pure function

The heart of the fix is a lookup against a short-lived idempotency store, keyed on (resource_id, new_status_id), holding the last-seen created_at epoch for that key. If a prior entry exists and the new event's timestamp is within the dedupe window of it, it is a duplicate and gets dropped. Otherwise the store is updated and the event is processed. Because the function only touches the dict you pass it, it needs no network and no store to unit test.

decide.py
def is_duplicate_webhook_event(
    seen_events: dict, resource_id: int, new_status_id: int,
    created_at_epoch: float, window_seconds: float = 2.0
) -> bool:
    key = (resource_id, new_status_id)
    last_seen = seen_events.get(key)
    if last_seen is not None and abs(created_at_epoch - last_seen) <= window_seconds:
        return True
    seen_events[key] = created_at_epoch
    return False
decide.js
export function isDuplicateWebhookEvent(
  seenEvents, resourceId, newStatusId, createdAtEpoch, windowSeconds = 2.0
) {
  const key = `${resourceId}:${newStatusId}`;
  const lastSeen = seenEvents.get(key);
  if (lastSeen !== undefined && Math.abs(createdAtEpoch - lastSeen) <= windowSeconds) {
    return true;
  }
  seenEvents.set(key, createdAtEpoch);
  return false;
}
4

Confirm the authoritative order state when it matters

The idempotency check alone is enough to stop a handler from double-processing. When you need certainty about what BigCommerce currently believes, rather than trusting the payload, call GET /v2/orders/{order_id} and compare its status_id and date_modified against the last value you recorded for that order.

confirm.py
def fetch_order_state(order_id):
    order = bc_get(f"/v2/orders/{order_id}")
    return {"status_id": order.get("status_id"), "date_modified": order.get("date_modified")}
confirm.js
async function fetchOrderState(orderId) {
  const order = await bcGet(`/v2/orders/${orderId}`);
  return { statusId: order.status_id, dateModified: order.date_modified };
}
5

Check for a duplicate hook registration

List every registered hook with GET /v3/hooks and group by scope plus destination. If two active hooks share the same scope, for example store/order/statusUpdated, and the same destination URL, every logical event to that endpoint is being delivered twice by design, not by a retry. That is a configuration bug, and it is worth fixing at the source.

hooks.py
def find_duplicate_hooks(scope, destination):
    hooks = bc_get("/v3/hooks")
    data = hooks.get("data", []) if isinstance(hooks, dict) else hooks
    matches = [h for h in data if h.get("scope") == scope and h.get("destination") == destination]
    matches.sort(key=lambda h: h.get("id", 0))
    return matches
hooks.js
async function findDuplicateHooks(scope, destination) {
  const hooks = await bcGet("/v3/hooks");
  const data = Array.isArray(hooks) ? hooks : hooks.data || [];
  const matches = data.filter((h) => h.scope === scope && h.destination === destination);
  matches.sort((a, b) => (a.id || 0) - (b.id || 0));
  return matches;
}
6

Wire it together with a dry run guard

The full script processes each inbound webhook payload through the idempotency check first. Anything flagged a duplicate is logged and dropped before your business logic runs. Separately, it lists /v3/hooks once, and if it finds more than one active hook on the same scope and destination, it keeps the oldest id and, only when DRY_RUN is false, deletes the redundant one with DELETE /v3/hooks/{hook_id}. When DRY_RUN is true, which is the default, it only reports the duplicate hooks and logs suspected duplicate deliveries, order id, status_id, timestamps, and hash, for manual review. It never writes to an order, because BigCommerce already applied the status_id correctly; the bug is redundant notification, not redundant mutation.

Run it safe

Always start with DRY_RUN=true. There is no BigCommerce endpoint to de-duplicate a delivery after the fact, so the only real write this script ever makes is deleting a confirmed redundant hook registration, and only after your own idempotency handling is already in place. Never use this job to alter an order's status_id, that value was already set correctly by BigCommerce.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, keeps the idempotency store in memory for the life of the process, logs what it does, respects the dry run flag, and only deletes a hook registration when it has confirmed a genuine duplicate scope and destination pair.

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

dedupe_same_second_webhooks.py
"""Drop duplicate BigCommerce store/order/statusUpdated events fired in the same second.

BigCommerce's webhook service guarantees at-least-once delivery, not exactly-once.
If your endpoint is slow, times out, or its 200 OK response is lost in transit, the
retry mechanism re-sends the same logical event. Separately, a single admin or API
action can legitimately trigger more than one webhook subscription in the same
second, and each carries its own created_at and a hash that is not guaranteed
stable, so hash alone cannot prove a duplicate. This script keeps a short-lived
idempotency store keyed on (resource_id, new_status_id) with created_at rounded
into a window, drops repeats inside that window, confirms the order's real state
with GET /v2/orders/{id} when needed, and separately checks GET /v3/hooks for a
duplicate hook registration on the same scope and destination, which is a common
misconfiguration that doubles every delivery. It never writes to order state.

Guide: https://www.allanninal.dev/bigcommerce/webhook-duplicate-events-same-second/
"""
import os
import logging

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dedupe_same_second_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}"
DEDUPE_WINDOW_SECONDS = float(os.environ.get("DEDUPE_WINDOW_SECONDS", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

WATCHED_SCOPE = "store/order/statusUpdated"

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()
    if not r.text:
        return []
    return r.json()


def bc_delete(path):
    r = requests.delete(f"{API_BASE}{path}", headers=HEADERS, timeout=30)
    r.raise_for_status()


def is_duplicate_webhook_event(
    seen_events: dict, resource_id: int, new_status_id: int,
    created_at_epoch: float, window_seconds: float = DEDUPE_WINDOW_SECONDS,
) -> bool:
    """Pure decision. No network, no side effects.

    seen_events maps (resource_id, new_status_id) to the last-seen created_at
    epoch for that key. If a prior entry exists within window_seconds of the
    new event, it is a duplicate: return True and leave the store untouched.
    Otherwise record created_at_epoch for the key and return False, meaning
    the event should be processed. A different new_status_id for the same
    resource_id is treated as a distinct event, never a duplicate of the
    other status.
    """
    key = (resource_id, new_status_id)
    last_seen = seen_events.get(key)
    if last_seen is not None and abs(created_at_epoch - last_seen) <= window_seconds:
        return True
    seen_events[key] = created_at_epoch
    return False


def fetch_order_state(order_id):
    """Confirm the authoritative order state instead of trusting the payload alone."""
    order = bc_get(f"/v2/orders/{order_id}")
    return {"status_id": order.get("status_id"), "date_modified": order.get("date_modified")}


def find_duplicate_hooks(scope, destination):
    """List /v3/hooks and return every active hook matching scope and destination,
    oldest id first. More than one entry means every delivery to that destination
    is doubled by configuration, not by a retry."""
    hooks = bc_get("/v3/hooks")
    data = hooks.get("data", []) if isinstance(hooks, dict) else hooks
    matches = [h for h in data if h.get("scope") == scope and h.get("destination") == destination]
    matches.sort(key=lambda h: h.get("id", 0))
    return matches


def handle_webhook_event(seen_events, payload):
    """Process one inbound store/order/statusUpdated payload.

    Returns "processed" or "dropped_duplicate". Never mutates order state:
    the order's status_id was already applied correctly by BigCommerce, the
    bug being guarded against is redundant notification delivery.
    """
    resource_id = payload["data"]["id"]
    new_status_id = payload["data"]["status"]["new_status_id"]
    created_at_epoch = payload["created_at"]
    event_hash = payload.get("hash")

    if is_duplicate_webhook_event(seen_events, resource_id, new_status_id, created_at_epoch):
        log.info(
            "Duplicate dropped. resource_id=%s new_status_id=%s created_at=%s hash=%s",
            resource_id, new_status_id, created_at_epoch, event_hash,
        )
        return "dropped_duplicate"

    log.info(
        "Processing event. resource_id=%s new_status_id=%s created_at=%s hash=%s",
        resource_id, new_status_id, created_at_epoch, event_hash,
    )
    return "processed"


def run(destination_url):
    duplicate_hooks = find_duplicate_hooks(WATCHED_SCOPE, destination_url)

    if len(duplicate_hooks) <= 1:
        log.info("No duplicate hook registration found for scope=%s destination=%s", WATCHED_SCOPE, destination_url)
        return

    keep = duplicate_hooks[0]
    redundant = duplicate_hooks[1:]
    log.warning(
        "Found %d hooks on scope=%s destination=%s. Keeping id=%s, redundant ids=%s",
        len(duplicate_hooks), WATCHED_SCOPE, destination_url, keep.get("id"),
        [h.get("id") for h in redundant],
    )

    for hook in redundant:
        if not DRY_RUN:
            bc_delete(f"/v3/hooks/{hook['id']}")
            log.info("Deleted redundant hook id=%s", hook["id"])
        else:
            log.info("Dry run: would delete redundant hook id=%s", hook["id"])


if __name__ == "__main__":
    run(destination_url=os.environ.get("WEBHOOK_DESTINATION_URL", "https://example.com/webhooks/bigcommerce"))
dedupe-same-second-webhooks.js
/**
 * Drop duplicate BigCommerce store/order/statusUpdated events fired in the same second.
 *
 * BigCommerce's webhook service guarantees at-least-once delivery, not exactly-once.
 * If your endpoint is slow, times out, or its 200 OK response is lost in transit, the
 * retry mechanism re-sends the same logical event. Separately, a single admin or API
 * action can legitimately trigger more than one webhook subscription in the same
 * second, and each carries its own created_at and a hash that is not guaranteed
 * stable, so hash alone cannot prove a duplicate. This script keeps a short-lived
 * idempotency store keyed on (resourceId, newStatusId) with createdAt rounded into
 * a window, drops repeats inside that window, confirms the order's real state with
 * GET /v2/orders/{id} when needed, and separately checks GET /v3/hooks for a
 * duplicate hook registration on the same scope and destination, a common
 * misconfiguration that doubles every delivery. It never writes to order state.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/webhook-duplicate-events-same-second/
 */
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}`;
const DEDUPE_WINDOW_SECONDS = Number(process.env.DEDUPE_WINDOW_SECONDS || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const WATCHED_SCOPE = "store/order/statusUpdated";

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

/**
 * Pure decision. No network, no side effects.
 *
 * seenEvents is a Map from "resourceId:newStatusId" to the last-seen createdAt
 * epoch for that key. If a prior entry exists within windowSeconds of the new
 * event, it is a duplicate: return true and leave the map untouched. Otherwise
 * record createdAtEpoch for the key and return false, meaning the event should
 * be processed. A different newStatusId for the same resourceId is treated as
 * a distinct event, never a duplicate of the other status.
 */
export function isDuplicateWebhookEvent(
  seenEvents, resourceId, newStatusId, createdAtEpoch, windowSeconds = DEDUPE_WINDOW_SECONDS
) {
  const key = `${resourceId}:${newStatusId}`;
  const lastSeen = seenEvents.get(key);
  if (lastSeen !== undefined && Math.abs(createdAtEpoch - lastSeen) <= windowSeconds) {
    return true;
  }
  seenEvents.set(key, createdAtEpoch);
  return false;
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

async function bcDelete(path) {
  const res = await fetch(`${API_BASE}${path}`, { method: "DELETE", headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
}

/** Confirm the authoritative order state instead of trusting the payload alone. */
export async function fetchOrderState(orderId) {
  const order = await bcGet(`/v2/orders/${orderId}`);
  return { statusId: order.status_id, dateModified: order.date_modified };
}

/**
 * List /v3/hooks and return every active hook matching scope and destination,
 * oldest id first. More than one entry means every delivery to that destination
 * is doubled by configuration, not by a retry.
 */
export async function findDuplicateHooks(scope, destination) {
  const hooks = await bcGet("/v3/hooks");
  const data = Array.isArray(hooks) ? hooks : hooks.data || [];
  const matches = data.filter((h) => h.scope === scope && h.destination === destination);
  matches.sort((a, b) => (a.id || 0) - (b.id || 0));
  return matches;
}

/**
 * Process one inbound store/order/statusUpdated payload. Returns "processed" or
 * "dropped_duplicate". Never mutates order state: the order's status_id was
 * already applied correctly by BigCommerce, the bug being guarded against is
 * redundant notification delivery.
 */
export function handleWebhookEvent(seenEvents, payload) {
  const resourceId = payload.data.id;
  const newStatusId = payload.data.status.new_status_id;
  const createdAtEpoch = payload.created_at;
  const eventHash = payload.hash;

  if (isDuplicateWebhookEvent(seenEvents, resourceId, newStatusId, createdAtEpoch)) {
    console.log(
      `Duplicate dropped. resource_id=${resourceId} new_status_id=${newStatusId} created_at=${createdAtEpoch} hash=${eventHash}`
    );
    return "dropped_duplicate";
  }

  console.log(
    `Processing event. resource_id=${resourceId} new_status_id=${newStatusId} created_at=${createdAtEpoch} hash=${eventHash}`
  );
  return "processed";
}

export async function run(destinationUrl = process.env.WEBHOOK_DESTINATION_URL || "https://example.com/webhooks/bigcommerce") {
  const duplicateHooks = await findDuplicateHooks(WATCHED_SCOPE, destinationUrl);

  if (duplicateHooks.length <= 1) {
    console.log(`No duplicate hook registration found for scope=${WATCHED_SCOPE} destination=${destinationUrl}`);
    return;
  }

  const keep = duplicateHooks[0];
  const redundant = duplicateHooks.slice(1);
  console.warn(
    `Found ${duplicateHooks.length} hooks on scope=${WATCHED_SCOPE} destination=${destinationUrl}. ` +
    `Keeping id=${keep.id}, redundant ids=${redundant.map((h) => h.id)}`
  );

  for (const hook of redundant) {
    if (!DRY_RUN) {
      await bcDelete(`/v3/hooks/${hook.id}`);
      console.log(`Deleted redundant hook id=${hook.id}`);
    } else {
      console.log(`Dry run: would delete redundant hook id=${hook.id}`);
    }
  }
}

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

Add a test

The idempotency check is the part most worth testing, because it decides whether your handler runs once or twice for the same real-world event. Because is_duplicate_webhook_event takes only plain values and a plain dict, the test needs no network and no webhook receiver. It just feeds in timestamps and checks the answer, including the exact boundary of the window.

test_webhook_duplicate_detection.py
from dedupe_same_second_webhooks import is_duplicate_webhook_event


def test_first_event_is_not_a_duplicate():
    seen = {}
    assert is_duplicate_webhook_event(seen, 501, 11, 1000.0) is False
    assert seen[(501, 11)] == 1000.0


def test_second_event_within_window_is_a_duplicate():
    seen = {(501, 11): 1000.0}
    assert is_duplicate_webhook_event(seen, 501, 11, 1000.8) is True


def test_event_exactly_at_window_edge_is_a_duplicate():
    seen = {(501, 11): 1000.0}
    assert is_duplicate_webhook_event(seen, 501, 11, 1002.0, window_seconds=2.0) is True


def test_event_just_outside_window_is_not_a_duplicate():
    seen = {(501, 11): 1000.0}
    assert is_duplicate_webhook_event(seen, 501, 11, 1002.1, window_seconds=2.0) is False


def test_out_of_order_timestamp_within_window_is_still_a_duplicate():
    seen = {(501, 11): 1005.0}
    assert is_duplicate_webhook_event(seen, 501, 11, 1004.0, window_seconds=2.0) is True


def test_different_status_id_is_a_distinct_event_not_a_duplicate():
    seen = {(501, 11): 1000.0}
    assert is_duplicate_webhook_event(seen, 501, 12, 1000.2) is False


def test_different_resource_id_is_a_distinct_event_not_a_duplicate():
    seen = {(501, 11): 1000.0}
    assert is_duplicate_webhook_event(seen, 502, 11, 1000.2) is False
dedupe-same-second-webhooks.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isDuplicateWebhookEvent } from "./dedupe-same-second-webhooks.js";

test("first event is not a duplicate", () => {
  const seen = new Map();
  assert.equal(isDuplicateWebhookEvent(seen, 501, 11, 1000.0), false);
  assert.equal(seen.get("501:11"), 1000.0);
});

test("second event within window is a duplicate", () => {
  const seen = new Map([["501:11", 1000.0]]);
  assert.equal(isDuplicateWebhookEvent(seen, 501, 11, 1000.8), true);
});

test("event exactly at window edge is a duplicate", () => {
  const seen = new Map([["501:11", 1000.0]]);
  assert.equal(isDuplicateWebhookEvent(seen, 501, 11, 1002.0, 2.0), true);
});

test("event just outside window is not a duplicate", () => {
  const seen = new Map([["501:11", 1000.0]]);
  assert.equal(isDuplicateWebhookEvent(seen, 501, 11, 1002.1, 2.0), false);
});

test("out of order timestamp within window is still a duplicate", () => {
  const seen = new Map([["501:11", 1005.0]]);
  assert.equal(isDuplicateWebhookEvent(seen, 501, 11, 1004.0, 2.0), true);
});

test("different status id is a distinct event, not a duplicate", () => {
  const seen = new Map([["501:11", 1000.0]]);
  assert.equal(isDuplicateWebhookEvent(seen, 501, 12, 1000.2), false);
});

test("different resource id is a distinct event, not a duplicate", () => {
  const seen = new Map([["501:11", 1000.0]]);
  assert.equal(isDuplicateWebhookEvent(seen, 502, 11, 1000.2), false);
});

Case studies

Slow handler, at-least-once retry

The integration that ran its fulfillment sync twice per order

A mid-size store's order management integration took a few seconds to write each status change to its own database before responding. Under load, that response sometimes arrived after BigCommerce's timeout, so BigCommerce retried the identical event. The integration had no idempotency check, so it ran its downstream fulfillment sync twice for the same status change, occasionally double-emailing the customer.

Adding the idempotency check in front of the handler fixed it without touching the slow handler itself. Duplicate deliveries inside the window are now dropped before the fulfillment sync ever runs, and the underlying slowness became a performance problem to fix separately, not a correctness bug.

Duplicate hook registration

The store with two hooks quietly pointed at the same URL

During a platform migration, a developer registered a new webhook subscription for store/order/statusUpdated pointed at the new endpoint, but the old registration on the same scope and destination was never removed. Every order status change was delivered twice from that point on, and the team initially suspected a BigCommerce retry bug.

Running GET /v3/hooks immediately showed two active entries with the same scope and destination. With DRY_RUN=true the script reported the duplicate and which id was older; once confirmed, flipping DRY_RUN to false removed the redundant one and deliveries dropped back to one per event.

What good looks like

After this is in place, a slow response or a lost acknowledgement no longer causes your handler to run twice, because the retry is caught by the idempotency window before it reaches your business logic. A genuine duplicate hook registration gets found and, once confirmed, removed, so every order status change is delivered exactly once at the transport level too. Nothing about the order's own status_id is ever touched, because it was already correct.

FAQ

Why does my BigCommerce webhook fire twice for the same order within a second?

BigCommerce guarantees at-least-once delivery, not exactly-once. If your endpoint is slow, times out, or its 200 OK response is lost in transit, the retry mechanism re-sends the same logical event. Separately, one admin or API action can legitimately trigger more than one webhook subscription, so two distinct, valid events for the same order can land within the same second.

Can I trust the hash field on the webhook payload to detect duplicates?

Not by itself. The hash is not guaranteed to be stable or independently verifiable across retries. Instead, key an idempotency check on store_id, resource_id, and new_status_id, with created_at rounded into a one to two second bucket, and confirm the authoritative state with a GET to the order endpoint when you need certainty.

Should I automatically delete a webhook subscription if I see duplicate deliveries?

Only if you first confirm there are genuinely two active hook registrations with the same scope and destination via GET /v3/hooks. If there is only one hook, the duplicates are retries, not fan-out, and there is nothing to delete. Deleting a hook is a real, destructive action, so keep it behind a DRY_RUN guard and only ever remove the redundant duplicate, keeping the oldest id.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: store/order/updated webhook sending multiple times within the same second, same status. support.bigcommerce.com storeorderupdated webhook sending multiple times
  2. BigCommerce Support: webhooks duplicate order status updates. support.bigcommerce.com webhooks duplicate order status updates
  3. BigCommerce Support: webhooks sent multiple times by BigCommerce. support.bigcommerce.com webhooks sent multiple times

On the solution:

  1. BigCommerce Developer Center: Webhooks overview and at-least-once delivery guarantee. developer.bigcommerce.com webhooks overview
  2. BigCommerce Developer Center: the store/order/statusUpdated payload model. developer.bigcommerce.com store/order/statusUpdated model
  3. BigCommerce Docs: Webhooks v3, the /v3/hooks endpoint. docs.bigcommerce.com webhooks v3

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 stop your handler from double firing?

If this saved you a pile of duplicate notifications or caught a stray hook registration, 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