Reconciler Checkout / Carts
Cart contents lost across devices or sessions in the B2B buyer portal
A wholesale buyer builds a big cart on their desktop at the office, then opens the B2B Buyer Portal on their phone in the warehouse and finds it empty. Nothing was deleted. A brand new anonymous cart was simply created in its place, because BigCommerce has no way to look up a customer's existing cart from a new device. Here is why that gap exists and a script that finds the orphaned duplicate carts piling up behind it, so you can see the damage before you touch anything.
BigCommerce carts are anonymous by default. A cart is created against a storefront checkout session's cart_id, and it only gets a customer_id attached when the shopper is logged in at the moment items are added to it. The B2B Buyer Portal has no reliable way to rehydrate a customer's prior cart on a new device or after a fresh login, because the Carts API has no "list carts by customer_id" endpoint, and the portal's session state (plus the storefront cart cookie) is scoped to the browser, not looked up server-side. The result is a new anonymous cart_id every time, with the old cart abandoned until BigCommerce auto-expires it after 30 days. You cannot query your way out of this after the fact without your own mapping table, so the safe move is to track {cart_id, customer_id, created_at, updated_at} yourself as carts are created, then run a script that groups carts by customer_id, flags duplicates, and reports (never silently merges) anything that needs a human. Full code, tests, and a dry run guard are below.
The problem in plain words
In BigCommerce, a cart is not a property of a customer account. It is its own resource, identified by a cart_id, that a storefront session happens to be pointed at. When a shopper is logged in and adds items, BigCommerce can attach that cart's customer_id so it counts as "theirs," either through storefront session binding or an explicit PUT /v3/carts/{cartId} that sets customer_id. But that link is a one-time labeling, not a standing lookup key you can query back later.
The B2B Buyer Portal is a single-page app layered on top of this. Its own session state, and the storefront's cart cookie underneath it, both live in the browser. When a buyer logs out, logs back in on a different device, or simply opens a new browser session, the portal has nothing server-side to ask "does this customer already have an open cart somewhere?" There is no GET /v3/carts?customer_id=... to call. So the portal does the only thing it can: it creates a brand new anonymous cart and starts fresh, leaving the old cart, and everything the buyer put in it, sitting untouched and unreferenced.
Why it happens
This is a structural gap in how the Carts API and the B2B Buyer Portal fit together, not a bug in any single request. A few concrete ways stores end up with buyers who "lost" their cart:
- A buyer adds items while logged in on one device, the cart's
customer_idgets set, then they open the portal on a second device or in a different browser, and the portal has no endpoint to ask BigCommerce "what cart_id belongs to this customer_id," so it starts a fresh one. - A buyer logs out and back in within the same browser. The storefront cart cookie is cleared or replaced on logout, and login does not automatically resolve back to the cart that was tied to their customer_id before, so a new anonymous cart_id is created.
- A buyer adds items anonymously (browsing before logging in), then logs in partway through. Some flows attach the existing cart's customer_id at that point; others start a second cart instead, leaving an anonymous, pre-login duplicate with the same line items sitting unlinked.
- Nothing expires these duplicates quickly. BigCommerce auto-expires an unmodified cart after 30 days, so until that window passes, every abandoned session leaves one more orphaned cart_id tied loosely, or not at all, to that customer.
This is a known pain point reported directly against the B2B Buyer Portal, and a recurring question in BigCommerce's own support community about what happens to cart contents across login and logout. See the citations at the end for the exact issue thread and support answers.
You cannot ask BigCommerce "show me every cart for customer 12345" after the fact. The Carts API has no such filter, so if you did not track the relationship between cart_id and customer_id as carts were created, you have to reconstruct it from your own checkout redirect events or order and webhook logs. Once you have that mapping, the fix is not to merge automatically. It is to group carts by customer_id, treat the most recently updated cart as canonical, and only ever delete an older orphan when its contents are a strict subset of the canonical cart, since a deleted cart cannot be recovered. Anything with extra items in the orphan gets reported for a human to merge by hand.
The fix, as a flow
We do not change how BigCommerce creates or binds carts. We add a reconciler that reads your own tracked mapping of carts to customers, calls the Carts API to check current state, classifies duplicates with a pure function, and only deletes the safe, subset case under an explicit dry run guard.
Build it step by step
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 Carts (modify) and Customers (read-only) scopes so it can read and delete carts and confirm customers still exist. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header, alongside Accept: application/json. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export CART_VALIDITY_DAYS="30"
export DRY_RUN="true" # start safe, change to false to delete confirmed orphans
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export CART_VALIDITY_DAYS="30"
export DRY_RUN="true" // start safe, change to false to delete confirmed orphans
Talk to the V3 Carts 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 DELETE and raises on a non-2xx response. We reuse it to read each cart's current state and, only when explicitly authorized, to delete a confirmed orphan.
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,
"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()
return True
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,
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}`);
return true;
}
Rebuild the cart to customer mapping, and read current cart state
BigCommerce does not expose a "list carts for customer_id" query, so you need your own {cart_id, customer_id, created_at, updated_at} table, tracked at creation time from checkout redirect events or order and webhook logs. For every cart_id in that table, call GET /v3/carts/{cartId} and read data.customer_id, data.updated_time, and data.line_items to get its live state, since a tracked cart may already be stale or gone.
def fetch_cart(cart_id):
"""Returns None if the cart is already gone (expired or deleted)."""
try:
resp = bc_get(f"/carts/{cart_id}")
except requests.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 404:
return None
raise
return resp.get("data")
def line_item_skus(cart_data):
physical = (cart_data.get("line_items") or {}).get("physical_items") or []
digital = (cart_data.get("line_items") or {}).get("digital_items") or []
return frozenset(item.get("sku") for item in [*physical, *digital] if item.get("sku"))
async function fetchCart(cartId) {
// Returns null if the cart is already gone (expired or deleted).
try {
const resp = await bcGet(`/carts/${cartId}`);
return resp.data || null;
} catch (err) {
if (String(err.message).includes("404")) return null;
throw err;
}
}
function lineItemSkus(cartData) {
const lineItems = cartData.line_items || {};
const physical = lineItems.physical_items || [];
const digital = lineItems.digital_items || [];
return new Set([...physical, ...digital].map((item) => item.sku).filter(Boolean));
}
Decide, with one pure function
Keep the classification in its own function that takes plain cart records and returns a plain grouping. It drops expired carts, groups the rest by customer_id, and inside every group with more than one live cart, picks the most recently updated one as canonical. Every other cart in the group is either safely deletable, because its items are a subset of the canonical cart, or needs a human, because it has items the canonical cart does not.
def classify_cart_duplicates(carts, now_epoch, validity_days=30):
live = [c for c in carts if (now_epoch - c["updated_time"]) <= validity_days * 86400]
by_customer = {}
for cart in live:
cid = cart.get("customer_id")
if not cid:
continue
by_customer.setdefault(str(cid), []).append(cart)
result = {}
for customer_id, group in by_customer.items():
if len(group) <= 1:
continue
canonical = max(group, key=lambda c: c["updated_time"])
deletable = []
needs_merge = []
for cart in group:
if cart["cart_id"] == canonical["cart_id"]:
continue
if cart["line_item_skus"] <= canonical["line_item_skus"]:
deletable.append(cart["cart_id"])
else:
needs_merge.append(cart["cart_id"])
result[customer_id] = {
"canonical": canonical["cart_id"],
"orphans_deletable": deletable,
"orphans_needs_merge": needs_merge,
}
return result
export function classifyCartDuplicates(carts, nowEpoch, validityDays = 30) {
const live = carts.filter((c) => nowEpoch - c.updated_time <= validityDays * 86400);
const byCustomer = new Map();
for (const cart of live) {
const cid = cart.customer_id;
if (!cid) continue;
const key = String(cid);
if (!byCustomer.has(key)) byCustomer.set(key, []);
byCustomer.get(key).push(cart);
}
const result = {};
for (const [customerId, group] of byCustomer) {
if (group.length <= 1) continue;
const canonical = group.reduce((a, b) => (b.updated_time > a.updated_time ? b : a));
const deletable = [];
const needsMerge = [];
for (const cart of group) {
if (cart.cart_id === canonical.cart_id) continue;
const isSubset = [...cart.line_item_skus].every((sku) => canonical.line_item_skus.has(sku));
if (isSubset) deletable.push(cart.cart_id);
else needsMerge.push(cart.cart_id);
}
result[customerId] = {
canonical: canonical.cart_id,
orphans_deletable: deletable,
orphans_needs_merge: needsMerge,
};
}
return result;
}
Confirm the customer still exists before touching anything
Cross-check every customer_id that shows up with duplicates against GET /v3/customers?id:in={ids}. This catches carts left behind after an account change or deletion, and it is a cheap sanity check before you ever call DELETE on anything tied to that customer.
def active_customer_ids(customer_ids):
if not customer_ids:
return set()
ids_param = ",".join(str(cid) for cid in customer_ids)
resp = bc_get("/customers", {"id:in": ids_param})
return {row["id"] for row in resp.get("data", [])}
async function activeCustomerIds(customerIds) {
if (!customerIds.length) return new Set();
const idsParam = customerIds.join(",");
const resp = await bcGet("/customers", { "id:in": idsParam });
return new Set((resp.data || []).map((row) => row.id));
}
Wire it together with a dry run guard, and never auto-merge
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs, for every customer with duplicates, which cart it considers canonical, which orphans it would delete, and which orphans it is refusing to touch because they need a manual merge. Read the output, agree with it, then switch it off. A confirmed-deletable orphan only gets removed with DELETE /v3/carts/{cart_id} once DRY_RUN=false is explicit, and an orphan with extra items is never deleted automatically, only reported.
Always start with DRY_RUN=true. Once a Cart has been deleted it cannot be recovered, so only call DELETE on an orphan whose line items are confirmed to be a subset of the canonical cart's contents, and never on a cart flagged for manual merge.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, rebuilds cart state from your own tracked cart-to-customer mapping, logs what it finds, respects the dry run flag, and only ever deletes an orphan whose contents are a strict subset of the canonical cart, reporting everything else for a human.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and safely clean up orphaned duplicate carts from the B2B Buyer Portal.
BigCommerce carts are anonymous by default. A cart is created against a
storefront checkout/session cart_id and only gets a customer_id attached when
the shopper is logged in at the moment items are added, via a PUT to
/v3/carts/{cartId} or storefront session binding. The B2B Buyer Portal has no
reliable way to rehydrate a customer's prior cart on a new device or after a
fresh login, because the Carts API has no "list carts by customer_id"
endpoint, and the portal's SPA state and the storefront cart cookie are both
scoped to the browser. Login, logout, and device switches therefore spawn a
new anonymous cart_id, and the old cart is simply abandoned until BigCommerce
auto-expires it after 30 days without modification.
This job rebuilds a {cart_id, customer_id, created_at, updated_at} mapping
from your own tracked source (checkout redirects, order or webhook logs),
re-reads each cart's live state from the Carts API, groups by customer_id, and
classifies duplicates with a pure function: the most recently updated cart is
canonical, an older cart whose items are a subset of the canonical cart is
safely deletable, and an older cart with items the canonical cart lacks is
flagged for a manual merge, never auto-merged or deleted. Deletion only
happens when DRY_RUN is explicitly turned off, because a deleted cart cannot
be recovered.
Guide: https://www.allanninal.dev/bigcommerce/b2b-cart-not-persisted-across-sessions/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_b2b_carts")
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"
CART_VALIDITY_DAYS = int(os.environ.get("CART_VALIDITY_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"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()
return True
def classify_cart_duplicates(carts, now_epoch, validity_days=30):
"""Pure decision. No network, no side effects.
carts: list of {"cart_id": str, "customer_id": int, "updated_time": int,
"line_item_skus": frozenset[str]}.
1. Drop expired carts, older than validity_days since updated_time.
2. Group remaining carts by customer_id, skipping anonymous (0/None) carts.
3. Within a group with more than one cart, canonical = max(updated_time).
4. Every other cart in the group is orphans_deletable if its SKUs are a
subset of canonical's SKUs, else orphans_needs_merge.
"""
live = [c for c in carts if (now_epoch - c["updated_time"]) <= validity_days * 86400]
by_customer = {}
for cart in live:
cid = cart.get("customer_id")
if not cid:
continue
by_customer.setdefault(str(cid), []).append(cart)
result = {}
for customer_id, group in by_customer.items():
if len(group) <= 1:
continue
canonical = max(group, key=lambda c: c["updated_time"])
deletable = []
needs_merge = []
for cart in group:
if cart["cart_id"] == canonical["cart_id"]:
continue
if cart["line_item_skus"] <= canonical["line_item_skus"]:
deletable.append(cart["cart_id"])
else:
needs_merge.append(cart["cart_id"])
result[customer_id] = {
"canonical": canonical["cart_id"],
"orphans_deletable": deletable,
"orphans_needs_merge": needs_merge,
}
return result
def fetch_cart(cart_id):
"""Returns None if the cart is already gone (expired or deleted)."""
try:
resp = bc_get(f"/carts/{cart_id}")
except requests.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 404:
return None
raise
return resp.get("data")
def line_item_skus(cart_data):
line_items = cart_data.get("line_items") or {}
physical = line_items.get("physical_items") or []
digital = line_items.get("digital_items") or []
return frozenset(item.get("sku") for item in [*physical, *digital] if item.get("sku"))
def active_customer_ids(customer_ids):
if not customer_ids:
return set()
ids_param = ",".join(str(cid) for cid in customer_ids)
resp = bc_get("/customers", {"id:in": ids_param})
return {row["id"] for row in resp.get("data", [])}
def load_tracked_cart_ids():
"""Replace this with your own store of tracked cart_ids.
BigCommerce has no endpoint to list carts, so this must come from your own
checkout redirect events, order logs, or webhook history captured at cart
creation time.
"""
raise NotImplementedError("Wire this up to your own cart_id tracking store")
def run():
import time
now_epoch = int(time.time())
tracked_ids = load_tracked_cart_ids()
carts = []
for cart_id in tracked_ids:
data = fetch_cart(cart_id)
if data is None:
continue
carts.append({
"cart_id": data["id"],
"customer_id": data.get("customer_id"),
"updated_time": data.get("updated_time", now_epoch),
"line_item_skus": line_item_skus(data),
})
duplicates = classify_cart_duplicates(carts, now_epoch, CART_VALIDITY_DAYS)
active_ids = active_customer_ids([int(cid) for cid in duplicates.keys()])
deleted = 0
flagged = 0
for customer_id, info in duplicates.items():
if int(customer_id) not in active_ids:
log.warning("customer_id=%s no longer active, skipping cleanup entirely", customer_id)
continue
for orphan_id in info["orphans_needs_merge"]:
log.warning(
"customer_id=%s orphan cart_id=%s needs manual merge into canonical cart_id=%s",
customer_id, orphan_id, info["canonical"],
)
flagged += 1
for orphan_id in info["orphans_deletable"]:
log.info(
"customer_id=%s orphan cart_id=%s is a subset of canonical cart_id=%s (%s)",
customer_id, orphan_id, info["canonical"],
"dry run" if DRY_RUN else "deleting",
)
if not DRY_RUN:
bc_delete(f"/carts/{orphan_id}")
deleted += 1
log.info(
"Done. %d orphan cart(s) %s, %d orphan(s) flagged for manual merge.",
deleted, "to delete" if DRY_RUN else "deleted", flagged,
)
if __name__ == "__main__":
run()
/**
* Find and safely clean up orphaned duplicate carts from the B2B Buyer Portal.
*
* BigCommerce carts are anonymous by default. A cart is created against a
* storefront checkout/session cart_id and only gets a customer_id attached
* when the shopper is logged in at the moment items are added, via a PUT to
* /v3/carts/{cartId} or storefront session binding. The B2B Buyer Portal has
* no reliable way to rehydrate a customer's prior cart on a new device or
* after a fresh login, because the Carts API has no "list carts by
* customer_id" endpoint, and the portal's SPA state and the storefront cart
* cookie are both scoped to the browser. Login, logout, and device switches
* therefore spawn a new anonymous cart_id, and the old cart is simply
* abandoned until BigCommerce auto-expires it after 30 days without
* modification.
*
* This job rebuilds a {cart_id, customer_id, created_at, updated_at} mapping
* from your own tracked source, re-reads each cart's live state from the
* Carts API, groups by customer_id, and classifies duplicates with a pure
* function: the most recently updated cart is canonical, an older cart whose
* items are a subset of the canonical cart is safely deletable, and an older
* cart with items the canonical cart lacks is flagged for a manual merge,
* never auto-merged or deleted. Deletion only happens when DRY_RUN is
* explicitly turned off, because a deleted cart cannot be recovered.
*
* Guide: https://www.allanninal.dev/bigcommerce/b2b-cart-not-persisted-across-sessions/
*/
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 CART_VALIDITY_DAYS = Number(process.env.CART_VALIDITY_DAYS || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* carts: array of {cart_id, customer_id, updated_time, line_item_skus: Set}.
*
* 1. Drop expired carts, older than validityDays since updated_time.
* 2. Group remaining carts by customer_id, skipping anonymous (0/null) carts.
* 3. Within a group with more than one cart, canonical = max(updated_time).
* 4. Every other cart in the group is orphans_deletable if its SKUs are a
* subset of canonical's SKUs, else orphans_needs_merge.
*/
export function classifyCartDuplicates(carts, nowEpoch, validityDays = 30) {
const live = carts.filter((c) => nowEpoch - c.updated_time <= validityDays * 86400);
const byCustomer = new Map();
for (const cart of live) {
const cid = cart.customer_id;
if (!cid) continue;
const key = String(cid);
if (!byCustomer.has(key)) byCustomer.set(key, []);
byCustomer.get(key).push(cart);
}
const result = {};
for (const [customerId, group] of byCustomer) {
if (group.length <= 1) continue;
const canonical = group.reduce((a, b) => (b.updated_time > a.updated_time ? b : a));
const deletable = [];
const needsMerge = [];
for (const cart of group) {
if (cart.cart_id === canonical.cart_id) continue;
const isSubset = [...cart.line_item_skus].every((sku) => canonical.line_item_skus.has(sku));
if (isSubset) deletable.push(cart.cart_id);
else needsMerge.push(cart.cart_id);
}
result[customerId] = {
canonical: canonical.cart_id,
orphans_deletable: deletable,
orphans_needs_merge: needsMerge,
};
}
return result;
}
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}`);
return true;
}
async function fetchCart(cartId) {
// Returns null if the cart is already gone (expired or deleted).
try {
const resp = await bcGet(`/carts/${cartId}`);
return resp.data || null;
} catch (err) {
if (String(err.message).includes("404")) return null;
throw err;
}
}
function lineItemSkus(cartData) {
const lineItems = cartData.line_items || {};
const physical = lineItems.physical_items || [];
const digital = lineItems.digital_items || [];
return new Set([...physical, ...digital].map((item) => item.sku).filter(Boolean));
}
async function activeCustomerIds(customerIds) {
if (!customerIds.length) return new Set();
const idsParam = customerIds.join(",");
const resp = await bcGet("/customers", { "id:in": idsParam });
return new Set((resp.data || []).map((row) => row.id));
}
/**
* Replace this with your own store of tracked cart_ids. BigCommerce has no
* endpoint to list carts, so this must come from your own checkout redirect
* events, order logs, or webhook history captured at cart creation time.
*/
async function loadTrackedCartIds() {
throw new Error("Wire this up to your own cart_id tracking store");
}
export async function run() {
const nowEpoch = Math.floor(Date.now() / 1000);
const trackedIds = await loadTrackedCartIds();
const carts = [];
for (const cartId of trackedIds) {
const data = await fetchCart(cartId);
if (data === null) continue;
carts.push({
cart_id: data.id,
customer_id: data.customer_id,
updated_time: data.updated_time ?? nowEpoch,
line_item_skus: lineItemSkus(data),
});
}
const duplicates = classifyCartDuplicates(carts, nowEpoch, CART_VALIDITY_DAYS);
const activeIds = await activeCustomerIds(Object.keys(duplicates).map(Number));
let deleted = 0;
let flagged = 0;
for (const [customerId, info] of Object.entries(duplicates)) {
if (!activeIds.has(Number(customerId))) {
console.warn(`customer_id=${customerId} no longer active, skipping cleanup entirely`);
continue;
}
for (const orphanId of info.orphans_needs_merge) {
console.warn(
`customer_id=${customerId} orphan cart_id=${orphanId} needs manual merge into canonical cart_id=${info.canonical}`
);
flagged += 1;
}
for (const orphanId of info.orphans_deletable) {
console.log(
`customer_id=${customerId} orphan cart_id=${orphanId} is a subset of canonical cart_id=${info.canonical} ` +
`(${DRY_RUN ? "dry run" : "deleting"})`
);
if (!DRY_RUN) await bcDelete(`/carts/${orphanId}`);
deleted += 1;
}
}
console.log(
`Done. ${deleted} orphan cart(s) ${DRY_RUN ? "to delete" : "deleted"}, ${flagged} orphan(s) flagged for manual merge.`
);
}
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 which carts are safe to delete. Because classify_cart_duplicates takes only plain values and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in plain records and checks the grouping.
from reconcile_b2b_carts import classify_cart_duplicates
NOW = 1_700_000_000
DAY = 86400
def cart(cart_id, customer_id, updated_time, skus):
return {
"cart_id": cart_id,
"customer_id": customer_id,
"updated_time": updated_time,
"line_item_skus": frozenset(skus),
}
def test_single_cart_per_customer_is_not_a_duplicate():
carts = [cart("A", 1, NOW, ["SKU-1"])]
assert classify_cart_duplicates(carts, NOW) == {}
def test_anonymous_carts_are_never_grouped():
carts = [cart("A", 0, NOW, ["SKU-1"]), cart("B", None, NOW, ["SKU-2"])]
assert classify_cart_duplicates(carts, NOW) == {}
def test_older_subset_cart_is_deletable():
carts = [
cart("old", 42, NOW - DAY, ["SKU-1"]),
cart("new", 42, NOW, ["SKU-1", "SKU-2"]),
]
result = classify_cart_duplicates(carts, NOW)
assert result["42"]["canonical"] == "new"
assert result["42"]["orphans_deletable"] == ["old"]
assert result["42"]["orphans_needs_merge"] == []
def test_older_cart_with_extra_items_needs_merge():
carts = [
cart("old", 42, NOW - DAY, ["SKU-1", "SKU-9"]),
cart("new", 42, NOW, ["SKU-1", "SKU-2"]),
]
result = classify_cart_duplicates(carts, NOW)
assert result["42"]["canonical"] == "new"
assert result["42"]["orphans_deletable"] == []
assert result["42"]["orphans_needs_merge"] == ["old"]
def test_expired_carts_are_dropped_before_grouping():
carts = [
cart("stale", 7, NOW - (31 * DAY), ["SKU-1"]),
cart("only-live", 7, NOW, ["SKU-1"]),
]
assert classify_cart_duplicates(carts, NOW) == {}
def test_three_way_duplicate_group():
carts = [
cart("a", 5, NOW - (2 * DAY), ["SKU-1"]),
cart("b", 5, NOW - DAY, ["SKU-1", "SKU-9"]),
cart("c", 5, NOW, ["SKU-1", "SKU-2"]),
]
result = classify_cart_duplicates(carts, NOW)
assert result["5"]["canonical"] == "c"
assert result["5"]["orphans_deletable"] == ["a"]
assert result["5"]["orphans_needs_merge"] == ["b"]
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyCartDuplicates } from "./reconcile-b2b-carts.js";
const NOW = 1_700_000_000;
const DAY = 86400;
const cart = (cartId, customerId, updatedTime, skus) => ({
cart_id: cartId,
customer_id: customerId,
updated_time: updatedTime,
line_item_skus: new Set(skus),
});
test("single cart per customer is not a duplicate", () => {
const carts = [cart("A", 1, NOW, ["SKU-1"])];
assert.deepEqual(classifyCartDuplicates(carts, NOW), {});
});
test("anonymous carts are never grouped", () => {
const carts = [cart("A", 0, NOW, ["SKU-1"]), cart("B", null, NOW, ["SKU-2"])];
assert.deepEqual(classifyCartDuplicates(carts, NOW), {});
});
test("older subset cart is deletable", () => {
const carts = [
cart("old", 42, NOW - DAY, ["SKU-1"]),
cart("new", 42, NOW, ["SKU-1", "SKU-2"]),
];
const result = classifyCartDuplicates(carts, NOW);
assert.equal(result["42"].canonical, "new");
assert.deepEqual(result["42"].orphans_deletable, ["old"]);
assert.deepEqual(result["42"].orphans_needs_merge, []);
});
test("older cart with extra items needs merge", () => {
const carts = [
cart("old", 42, NOW - DAY, ["SKU-1", "SKU-9"]),
cart("new", 42, NOW, ["SKU-1", "SKU-2"]),
];
const result = classifyCartDuplicates(carts, NOW);
assert.equal(result["42"].canonical, "new");
assert.deepEqual(result["42"].orphans_deletable, []);
assert.deepEqual(result["42"].orphans_needs_merge, ["old"]);
});
test("expired carts are dropped before grouping", () => {
const carts = [
cart("stale", 7, NOW - 31 * DAY, ["SKU-1"]),
cart("only-live", 7, NOW, ["SKU-1"]),
];
assert.deepEqual(classifyCartDuplicates(carts, NOW), {});
});
test("three way duplicate group", () => {
const carts = [
cart("a", 5, NOW - 2 * DAY, ["SKU-1"]),
cart("b", 5, NOW - DAY, ["SKU-1", "SKU-9"]),
cart("c", 5, NOW, ["SKU-1", "SKU-2"]),
];
const result = classifyCartDuplicates(carts, NOW);
assert.equal(result["5"].canonical, "c");
assert.deepEqual(result["5"].orphans_deletable, ["a"]);
assert.deepEqual(result["5"].orphans_needs_merge, ["b"]);
});
Case studies
The distributor whose reps kept starting over on the floor
A wholesale distributor's sales reps would build large multi-line orders in the B2B Buyer Portal at their desk, then walk out to the warehouse and pull up the same portal on a phone to confirm stock. Every time, the phone showed an empty cart. Reps assumed the portal was buggy and just rebuilt the order from a paper list, then had support delete the "extra" cart from the admin later.
Once the reconciler ran nightly against the store's own checkout-redirect log, it surfaced that dozens of reps had two or three live carts each, most of them clean subsets of whatever the rep had built most recently. Support switched from manually hunting for stray carts to reviewing a short daily report of the handful that genuinely needed a merge.
The buyer who added items, then logged in, and lost half the cart
A recurring B2B buyer would browse the catalog and add a handful of items before logging in, since the portal did not require authentication to add to cart. After logging in partway through, a second cart got the customer_id, while the first, anonymous cart kept the earlier items and was never reattached.
The classification function caught this because both carts eventually showed up against the same customer_id once the mapping was reconstructed from order logs. The anonymous cart's SKUs were not fully contained in the newer cart, so it was correctly flagged for a manual merge instead of being silently deleted, and the buyer's original items were recovered by hand.
After this runs on a schedule, every customer with more than one live cart shows up in a short, clear report: one canonical cart, a list of orphans that are safe to remove because they add nothing new, and a list of orphans that need a person to actually look at the two carts side by side. Nothing gets deleted without a subset check passing first, and nothing gets merged automatically, so a buyer never loses items they cannot get back.
FAQ
Why does a B2B buyer's cart disappear when they log in on a different device?
BigCommerce carts are anonymous by default. A cart is created against a storefront session cart_id, and it only gets a customer_id attached when the shopper is logged in at the moment items are added. The B2B Buyer Portal's session state and the storefront cart cookie are scoped to the browser, not looked up server-side by customer_id, and the Carts API has no endpoint to list carts for a given customer_id, so a new device or a fresh login simply spawns a new anonymous cart_id instead of resuming the old one.
Can I just auto-merge the old cart into the new one?
Not automatically. The Carts API has no merge-line-items primitive, and deleting a cart the customer might still have open in another tab risks real data loss, since a deleted cart cannot be recovered. The safe pattern is to keep the most recently updated cart as canonical, and only delete an older orphan once its contents are confirmed to be a subset of the canonical cart. Anything with items not present in the canonical cart should be reported for a human to merge, not deleted.
How long do these orphaned duplicate carts stick around if nobody cleans them up?
BigCommerce automatically expires a cart after 30 days without modification. Until that window passes, every abandoned cart from a prior device or session keeps accumulating against that customer, so a buyer who regularly switches devices can end up with several live duplicate carts at once, each with its own partial set of line items.
Related field notes
Citations
On the problem:
- GitHub Issue: Persist cart not working while using buyer portal, bigcommerce/b2b-buyer-portal #73. github.com bigcommerce/b2b-buyer-portal issue #73
- BigCommerce Support Community: Update/clear cart products when a customer logs in/out. support.bigcommerce.com update/clear cart on login/out
- BigCommerce Support Community: How long do items stay in a customer's cart if they are logged into their account. support.bigcommerce.com how long do items stay in a cart
On the solution:
- BigCommerce Developer Center: Carts Single, GET/PUT/DELETE /v3/carts/{cartId}, customer_id, optimistic concurrency. developer.bigcommerce.com carts single
- BigCommerce Developer Center: Carts overview, cart lifecycle, 30 day validity. developer.bigcommerce.com carts
- BigCommerce Docs: Customers V3, GET /v3/customers, id:in filtering. developer.bigcommerce.com customers
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this untangle your duplicate carts?
If this helped you see what was really happening to your B2B buyers' carts, 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