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.
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.
Why it happens
Nothing in the delivery mechanism promises at-most-once processing. A few ordinary ways stores end up with duplicates:
- The destination endpoint is slow, times out, or returns a non-2xx status, so BigCommerce's dispatcher retries the identical event for up to about 48 hours, up to 11 attempts, before finally setting the hook's
is_activeto false. - Rapid back-to-back admin actions on the same resource, such as toggling a product's purchasability twice or updating an order's status twice within a second, generate multiple distinct-but-near-identical events only a couple of seconds apart.
- Duplicate or leftover hook registrations for the same
scopeanddestination, left behind from re-running app install or setup code, cause BigCommerce to fire the same logical event once per matching active hook. - The webhook payload itself carries no dedicated delivery id, only
scope,store_id,data,hash,created_at, andproducer, so a handler that just executes on every POST has nothing built in to tell a resend apart from a new event.
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.
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.
Build it step by step
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.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true" # start safe, change to false to write
// 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
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.
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
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;
}
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.
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}
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;
}
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.
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"}
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" };
}
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.
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
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;
}
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.
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.
"""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()
/**
* 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.
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"
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
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.
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.
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:
- BigCommerce Developer Center: Webhooks Overview. developer.bigcommerce.com/docs/integrations/webhooks
- BigCommerce Support: store/order/updated webhook sending multiple times within the same second and same status. support.bigcommerce.com storeorderupdated webhook sending multiple times
- BigCommerce Support: webhook called twice. support.bigcommerce.com webhook called twice
On the solution:
- BigCommerce API Reference: Webhooks v3. docs.bigcommerce.com developer api-reference webhooks
- BigCommerce Developer Center: Webhooks Admin. developer.bigcommerce.com/docs/webhooks/webhooks/webhooks-admin
- 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.
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