Reconciler Migration and housekeeping
Abandoned cart records pile up in BigCommerce
Open the cart list in a store that has been live for a year or two and the count is startling. Thousands of records, most of them empty or long dead, and no button anywhere that clears them out. BigCommerce carts are created liberally by guest checkouts, recovery flows, headless sessions, and apps, and none of them ever expire or self-delete on the server. Here is why the pile keeps growing and a small script that classifies the mess and cleans up only what is truly safe to remove.
A BigCommerce V3 cart, created through the Storefront or Management Cart API, has no expiry and no server-side garbage collection. It sits there forever until it converts to an order or is explicitly deleted. Run a small Python or Node.js script that pages GET /v3/carts, reads each cart's updated_time and line item counts, cross-checks GET /v2/orders to see if the cart actually converted, and classifies each one with a pure function into empty_cart, converted_duplicate, abandoned_stale, or active. Only the first two reasons are ever hard deleted with DELETE /v3/carts/{cartId}. A real abandoned cart with items and no confirmed order is only ever flagged for a human to review, never auto-deleted. Full code, tests, and a dry run guard are below.
The problem in plain words
When a shopper starts a checkout on a BigCommerce store, or a headless storefront calls the Cart API to build a session, or an app builds a cart programmatically, BigCommerce creates a cart record with a UUID and stores it. That is normal and necessary. The trouble starts after.
There is no lifecycle for that record. It does not expire after an hour, a day, or a year. BigCommerce's own "abandoned cart" definition, an hour of inactivity, only triggers a recovery email to the shopper. It does nothing to the underlying cart object. So the cart just sits in the store's data forever, whether it ever had a single item in it or not, until either the shopper finishes checkout and it converts to an order, or someone calls the delete endpoint on it directly. Almost nobody calls that endpoint, so almost nothing ever gets deleted.
Why it happens
BigCommerce's cart model is intentionally simple: create, update, convert, or delete. It leaves the deleting part entirely up to the merchant or their tooling. A few common ways stores end up with a growing pile:
- Guest checkouts that never finish, where the shopper closes the tab and the cart just sits there with whatever items were in it.
- Headless storefronts that create a new cart on every anonymous visit, so a single browsing session that never buys anything can still leave a cart behind.
- Abandoned-cart-recovery emails and marketing apps that read carts constantly but were never built to clean any of them up, since that is not their job.
- A checkout that completes through a different code path, for example a payment webhook that creates the order directly, leaving the original cart object orphaned even though the sale went through.
None of this shows up as an error. The store just quietly accumulates cart records, and any tool that has to enumerate carts, whether it is a reporting dashboard or your own integration paging GET /v3/carts, gets slower and noisier every month the pile is left alone. See the citations at the end for the exact docs and threads.
Not every stale cart is safe to delete the same way. An empty cart or one that clearly duplicates a completed order is just noise, and removing it destroys nothing. But a stale cart that still holds real items and has no matching order might be exactly the cart a returning shopper expects to see when they come back. So the safe pattern is not "delete anything old." It is "classify why a cart is stale, hard delete only the reasons that carry no risk, and flag the rest for a human or a recovery workflow to act on before anything is purged."
The fix, as a flow
We do not touch active carts. We add a job that lists every cart, reads its age and its contents, checks whether a matching order already exists, and runs each one through a pure classifier. Two of the reason codes are safe to hard delete outright. The third, a real cart with real items and no confirmed order, is only ever tagged for review so marketing or recovery workflows can act on it before a later, explicitly approved sweep removes it.
Build it step by step
Get a store hash and an access token
Create an API account in your BigCommerce control panel under Settings, API, Store-level API accounts. Read and modify access to Carts, and read access to Orders, is enough for this job. Keep the store hash and access token in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export STALE_DAYS="30"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export STALE_DAYS="30"
export DRY_RUN="true" // start safe, change to false to write
Talk to the API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/ with your token in the X-Auth-Token header. A small helper sends the request and raises on a bad status. V3 responses wrap the payload in data and meta.pagination, while V2 responses are plain JSON with no envelope.
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()
if not r.content:
return None
return r.json()
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;
}
Page every cart, then check for a matching order
Page GET /v3/carts using meta.pagination to walk the whole list, reading each cart's id, customer_id, email, created_time, updated_time, and line item arrays. For any cart tied to a customer, call GET /v2/orders?customer_id={id}&min_date_created=... to see whether the checkout actually completed through a different path and left this cart object orphaned.
def all_carts():
"""Yield every cart, paginated using meta.pagination."""
page = 1
limit = 250
while True:
body = bc("GET", f"/v3/carts?limit={limit}&page={page}") or {}
batch = body.get("data") or []
if not batch:
return
for cart in batch:
yield cart
pagination = (body.get("meta") or {}).get("pagination") or {}
if page >= pagination.get("total_pages", page):
return
page += 1
def has_matching_order(customer_id, min_date_created):
"""True if the customer has any order placed at or after min_date_created."""
if not customer_id:
return False
qs = f"customer_id={customer_id}&min_date_created={min_date_created}&limit=1"
orders = bc("GET", f"/v2/orders?{qs}") or []
return len(orders) > 0
def line_item_counts(cart):
items = cart.get("line_items") or {}
return {
"physical": len(items.get("physical_items") or []),
"digital": len(items.get("digital_items") or []),
"custom": len(items.get("custom_items") or []),
"giftCert": len(items.get("gift_certificates") or []),
}
async function* allCarts() {
const limit = 250;
let page = 1;
while (true) {
const body = (await bc("GET", `/v3/carts?limit=${limit}&page=${page}`)) || {};
const batch = body.data || [];
if (!batch.length) return;
for (const cart of batch) yield cart;
const pagination = (body.meta || {}).pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
async function hasMatchingOrder(customerId, minDateCreated) {
if (!customerId) return false;
const qs = `customer_id=${customerId}&min_date_created=${minDateCreated}&limit=1`;
const orders = (await bc("GET", `/v2/orders?${qs}`)) || [];
return orders.length > 0;
}
function lineItemCounts(cart) {
const items = cart.line_items || {};
return {
physical: (items.physical_items || []).length,
digital: (items.digital_items || []).length,
custom: (items.custom_items || []).length,
giftCert: (items.gift_certificates || []).length,
};
}
Classify with one pure function
Keep the decision in its own function that takes a cart, whether a matching order exists, and the stale threshold, and returns a reason. A pure function like this is easy to read and easy to test, which we do later. An empty cart past the threshold is empty_cart, safe to delete since there is nothing in it. A cart with a confirmed matching order is converted_duplicate regardless of age, safe to delete since it is an orphaned leftover from a checkout that completed elsewhere. A cart with real items, past the threshold, and no matching order is abandoned_stale, flag only. Everything else is active.
from datetime import datetime
def classify_stale_cart(cart, matching_order_exists, now_iso, stale_days=30):
"""cart: {"id","customerId","email","createdTime","updatedTime","lineItemCounts"}
Pure, no I/O. Returns {"isStale": bool, "reason": str}."""
updated = datetime.fromisoformat(cart["updatedTime"].replace("Z", "+00:00"))
now = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
age_days = (now - updated).total_seconds() / 86400
counts = cart["lineItemCounts"]
total_items = counts["physical"] + counts["digital"] + counts["custom"] + counts["giftCert"]
if total_items == 0 and age_days > stale_days:
return {"isStale": True, "reason": "empty_cart"}
if matching_order_exists:
return {"isStale": True, "reason": "converted_duplicate"}
if total_items > 0 and age_days > stale_days and not matching_order_exists:
return {"isStale": True, "reason": "abandoned_stale"}
return {"isStale": False, "reason": "active"}
export function classifyStaleCart(cart, matchingOrderExists, nowIso, staleDays = 30) {
const updated = new Date(cart.updatedTime);
const now = new Date(nowIso);
const ageDays = (now - updated) / 86400000;
const counts = cart.lineItemCounts;
const totalItems = counts.physical + counts.digital + counts.custom + counts.giftCert;
if (totalItems === 0 && ageDays > staleDays) {
return { isStale: true, reason: "empty_cart" };
}
if (matchingOrderExists) {
return { isStale: true, reason: "converted_duplicate" };
}
if (totalItems > 0 && ageDays > staleDays && !matchingOrderExists) {
return { isStale: true, reason: "abandoned_stale" };
}
return { isStale: false, reason: "active" };
}
Act on the reason, never the same way twice
For empty_cart and converted_duplicate, call DELETE /v3/carts/{cartId} when DRY_RUN is false, since both are verifiably safe with no customer-facing data loss risk. For abandoned_stale, never call delete. Instead write a report row, or tag the cart with a custom metafield through /v3/carts/{cartId}/metafields, marking stale: true and staleSince: updatedTime, so a recovery workflow or a human can act on it before a later, explicitly approved sweep.
SAFE_DELETE_REASONS = frozenset({"empty_cart", "converted_duplicate"})
def delete_cart(cart_id):
"""Hard delete. Only ever called for a safe-delete reason."""
bc("DELETE", f"/v3/carts/{cart_id}")
def flag_cart_for_review(cart_id, updated_time):
"""Tag a real abandoned cart for review. Never deletes it."""
bc("POST", f"/v3/carts/{cart_id}/metafields", json={
"key": "stale",
"value": "true",
"namespace": "cart_cleanup",
"permission_set": "write",
})
bc("POST", f"/v3/carts/{cart_id}/metafields", json={
"key": "staleSince",
"value": updated_time,
"namespace": "cart_cleanup",
"permission_set": "write",
})
const SAFE_DELETE_REASONS = new Set(["empty_cart", "converted_duplicate"]);
async function deleteCart(cartId) {
// Hard delete. Only ever called for a safe-delete reason.
await bc("DELETE", `/v3/carts/${cartId}`);
}
async function flagCartForReview(cartId, updatedTime) {
// Tag a real abandoned cart for review. Never deletes it.
await bc("POST", `/v3/carts/${cartId}/metafields`, {
key: "stale",
value: "true",
namespace: "cart_cleanup",
permission_set: "write",
});
await bc("POST", `/v3/carts/${cartId}/metafields`, {
key: "staleSince",
value: updatedTime,
namespace: "cart_cleanup",
permission_set: "write",
});
}
Wire it together with a dry run guard
The loop pages every cart, checks for a matching order, classifies it, and logs what it would do. DRY_RUN controls whether the delete or the review flag actually writes. Run it on a schedule, for example nightly, so the pile never has the chance to grow unnoticed for months at a time.
Always start with DRY_RUN=true. Only empty_cart and converted_duplicate reach a hard delete. Any cart with real items and no confirmed order is only ever flagged, never deleted automatically, since it might be a cart a returning shopper still expects to see.
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 only ever hard deletes a cart for one of the two verifiably safe reasons.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and safely clean up BigCommerce cart records that have piled up.
A BigCommerce V3 cart, created via the Storefront or Management Cart API, never
expires and never self-deletes on the server. It persists indefinitely until it
either converts to an order or is explicitly deleted through the API. Guest
checkouts, abandoned-cart-recovery emails, headless storefront sessions, and app
integrations all create carts liberally, and the "abandoned" definition (one hour
of inactivity) only triggers a recovery email, never any cleanup. Stores end up
with a growing pile of stale, empty, or orphaned cart records with no built-in
garbage collection.
This pages GET /v3/carts, reads each cart's age and line item counts, cross-checks
GET /v2/orders to see if the cart actually converted through a different path,
and classifies each cart with a pure function into empty_cart, converted_duplicate,
abandoned_stale, or active. Only empty_cart and converted_duplicate are ever hard
deleted with DELETE /v3/carts/{cartId}. abandoned_stale carts, which still have
real items and no confirmed order, are only ever flagged for review, never
auto-deleted. Guarded by DRY_RUN. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/abandoned-cart-records-pile-up/
"""
import os
import logging
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("clean_stale_carts")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
STALE_DAYS = int(os.environ.get("STALE_DAYS", "30"))
SAFE_DELETE_REASONS = frozenset({"empty_cart", "converted_duplicate"})
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()
if not r.content:
return None
return r.json()
def classify_stale_cart(cart, matching_order_exists, now_iso, stale_days=30):
"""cart: {"id","customerId","email","createdTime","updatedTime","lineItemCounts"}
Pure, no I/O. Decision logic only, no network calls.
Returns {"isStale": bool, "reason": str}."""
updated = datetime.fromisoformat(cart["updatedTime"].replace("Z", "+00:00"))
now = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
age_days = (now - updated).total_seconds() / 86400
counts = cart["lineItemCounts"]
total_items = counts["physical"] + counts["digital"] + counts["custom"] + counts["giftCert"]
if total_items == 0 and age_days > stale_days:
return {"isStale": True, "reason": "empty_cart"}
if matching_order_exists:
return {"isStale": True, "reason": "converted_duplicate"}
if total_items > 0 and age_days > stale_days and not matching_order_exists:
return {"isStale": True, "reason": "abandoned_stale"}
return {"isStale": False, "reason": "active"}
def all_carts():
page = 1
limit = 250
while True:
body = bc("GET", f"/v3/carts?limit={limit}&page={page}") or {}
batch = body.get("data") or []
if not batch:
return
for cart in batch:
yield cart
pagination = (body.get("meta") or {}).get("pagination") or {}
if page >= pagination.get("total_pages", page):
return
page += 1
def has_matching_order(customer_id, min_date_created):
if not customer_id:
return False
qs = f"customer_id={customer_id}&min_date_created={min_date_created}&limit=1"
orders = bc("GET", f"/v2/orders?{qs}") or []
return len(orders) > 0
def line_item_counts(cart):
items = cart.get("line_items") or {}
return {
"physical": len(items.get("physical_items") or []),
"digital": len(items.get("digital_items") or []),
"custom": len(items.get("custom_items") or []),
"giftCert": len(items.get("gift_certificates") or []),
}
def normalize_cart(cart):
return {
"id": cart["id"],
"customerId": cart.get("customer_id"),
"email": cart.get("email"),
"createdTime": cart.get("created_time"),
"updatedTime": cart.get("updated_time"),
"lineItemCounts": line_item_counts(cart),
}
def delete_cart(cart_id):
"""Hard delete. Only ever called for a safe-delete reason."""
bc("DELETE", f"/v3/carts/{cart_id}")
def flag_cart_for_review(cart_id, updated_time):
"""Tag a real abandoned cart for review. Never deletes it."""
bc("POST", f"/v3/carts/{cart_id}/metafields", json={
"key": "stale", "value": "true", "namespace": "cart_cleanup", "permission_set": "write",
})
bc("POST", f"/v3/carts/{cart_id}/metafields", json={
"key": "staleSince", "value": updated_time, "namespace": "cart_cleanup", "permission_set": "write",
})
def run():
now_iso = datetime.now(timezone.utc).isoformat()
deleted = 0
flagged = 0
for raw_cart in all_carts():
cart = normalize_cart(raw_cart)
matching_order_exists = has_matching_order(cart["customerId"], cart["createdTime"])
result = classify_stale_cart(cart, matching_order_exists, now_iso, STALE_DAYS)
if not result["isStale"]:
continue
if result["reason"] in SAFE_DELETE_REASONS:
log.info("Cart %s reason=%s. %s", cart["id"], result["reason"],
"would delete" if DRY_RUN else "deleting")
if not DRY_RUN:
delete_cart(cart["id"])
deleted += 1
else:
log.info("Cart %s reason=%s staleSince=%s. %s", cart["id"], result["reason"], cart["updatedTime"],
"would flag" if DRY_RUN else "flagging")
if not DRY_RUN:
flag_cart_for_review(cart["id"], cart["updatedTime"])
flagged += 1
log.info("Done. %d cart(s) %s, %d cart(s) %s.",
deleted, "to delete" if DRY_RUN else "deleted",
flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Find and safely clean up BigCommerce cart records that have piled up.
*
* A BigCommerce V3 cart, created via the Storefront or Management Cart API,
* never expires and never self-deletes on the server. It persists indefinitely
* until it either converts to an order or is explicitly deleted through the
* API. Guest checkouts, abandoned-cart-recovery emails, headless storefront
* sessions, and app integrations all create carts liberally, and the
* "abandoned" definition (one hour of inactivity) only triggers a recovery
* email, never any cleanup. Stores end up with a growing pile of stale,
* empty, or orphaned cart records with no built-in garbage collection.
*
* This pages GET /v3/carts, reads each cart's age and line item counts,
* cross-checks GET /v2/orders to see if the cart actually converted through a
* different path, and classifies each cart with a pure function into
* empty_cart, converted_duplicate, abandoned_stale, or active. Only
* empty_cart and converted_duplicate are ever hard deleted with
* DELETE /v3/carts/{cartId}. abandoned_stale carts, which still have real
* items and no confirmed order, are only ever flagged for review, never
* auto-deleted. Guarded by DRY_RUN. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/abandoned-cart-records-pile-up/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "dummy_store_hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const STALE_DAYS = parseInt(process.env.STALE_DAYS || "30", 10);
const SAFE_DELETE_REASONS = new Set(["empty_cart", "converted_duplicate"]);
export function classifyStaleCart(cart, matchingOrderExists, nowIso, staleDays = 30) {
const updated = new Date(cart.updatedTime);
const now = new Date(nowIso);
const ageDays = (now - updated) / 86400000;
const counts = cart.lineItemCounts;
const totalItems = counts.physical + counts.digital + counts.custom + counts.giftCert;
if (totalItems === 0 && ageDays > staleDays) {
return { isStale: true, reason: "empty_cart" };
}
if (matchingOrderExists) {
return { isStale: true, reason: "converted_duplicate" };
}
if (totalItems > 0 && ageDays > staleDays && !matchingOrderExists) {
return { isStale: true, reason: "abandoned_stale" };
}
return { isStale: false, reason: "active" };
}
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* allCarts() {
const limit = 250;
let page = 1;
while (true) {
const body = (await bc("GET", `/v3/carts?limit=${limit}&page=${page}`)) || {};
const batch = body.data || [];
if (!batch.length) return;
for (const cart of batch) yield cart;
const pagination = (body.meta || {}).pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
async function hasMatchingOrder(customerId, minDateCreated) {
if (!customerId) return false;
const qs = `customer_id=${customerId}&min_date_created=${minDateCreated}&limit=1`;
const orders = (await bc("GET", `/v2/orders?${qs}`)) || [];
return orders.length > 0;
}
function lineItemCounts(cart) {
const items = cart.line_items || {};
return {
physical: (items.physical_items || []).length,
digital: (items.digital_items || []).length,
custom: (items.custom_items || []).length,
giftCert: (items.gift_certificates || []).length,
};
}
function normalizeCart(cart) {
return {
id: cart.id,
customerId: cart.customer_id,
email: cart.email,
createdTime: cart.created_time,
updatedTime: cart.updated_time,
lineItemCounts: lineItemCounts(cart),
};
}
async function deleteCart(cartId) {
// Hard delete. Only ever called for a safe-delete reason.
await bc("DELETE", `/v3/carts/${cartId}`);
}
async function flagCartForReview(cartId, updatedTime) {
// Tag a real abandoned cart for review. Never deletes it.
await bc("POST", `/v3/carts/${cartId}/metafields`, {
key: "stale", value: "true", namespace: "cart_cleanup", permission_set: "write",
});
await bc("POST", `/v3/carts/${cartId}/metafields`, {
key: "staleSince", value: updatedTime, namespace: "cart_cleanup", permission_set: "write",
});
}
export async function run() {
const nowIso = new Date().toISOString();
let deleted = 0;
let flagged = 0;
for await (const rawCart of allCarts()) {
const cart = normalizeCart(rawCart);
const matchingOrderExists = await hasMatchingOrder(cart.customerId, cart.createdTime);
const result = classifyStaleCart(cart, matchingOrderExists, nowIso, STALE_DAYS);
if (!result.isStale) continue;
if (SAFE_DELETE_REASONS.has(result.reason)) {
console.log(`Cart ${cart.id} reason=${result.reason}. ${DRY_RUN ? "would delete" : "deleting"}`);
if (!DRY_RUN) await deleteCart(cart.id);
deleted++;
} else {
console.log(`Cart ${cart.id} reason=${result.reason} staleSince=${cart.updatedTime}. ${DRY_RUN ? "would flag" : "flagging"}`);
if (!DRY_RUN) await flagCartForReview(cart.id, cart.updatedTime);
flagged++;
}
}
console.log(`Done. ${deleted} cart(s) ${DRY_RUN ? "to delete" : "deleted"}, ${flagged} cart(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
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 get hard deleted and which only ever get flagged. Because we kept classify_stale_cart pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from clean_stale_carts import classify_stale_cart
NOW = "2026-07-10T00:00:00+00:00"
def cart(updated_time="2026-07-09T00:00:00+00:00", physical=1, digital=0, custom=0, gift_cert=0):
return {
"id": "cart-1",
"customerId": 42,
"email": "buyer@example.com",
"createdTime": updated_time,
"updatedTime": updated_time,
"lineItemCounts": {"physical": physical, "digital": digital, "custom": custom, "giftCert": gift_cert},
}
def test_active_when_recent_and_has_items():
c = cart(updated_time="2026-07-09T00:00:00+00:00")
result = classify_stale_cart(c, False, NOW, stale_days=30)
assert result == {"isStale": False, "reason": "active"}
def test_empty_cart_past_threshold_is_safe_to_delete():
c = cart(updated_time="2026-05-01T00:00:00+00:00", physical=0)
result = classify_stale_cart(c, False, NOW, stale_days=30)
assert result == {"isStale": True, "reason": "empty_cart"}
def test_empty_but_recent_is_active():
c = cart(updated_time="2026-07-05T00:00:00+00:00", physical=0)
result = classify_stale_cart(c, False, NOW, stale_days=30)
assert result["isStale"] is False
def test_converted_duplicate_regardless_of_age():
c = cart(updated_time="2026-07-09T00:00:00+00:00", physical=2)
result = classify_stale_cart(c, True, NOW, stale_days=30)
assert result == {"isStale": True, "reason": "converted_duplicate"}
def test_abandoned_stale_has_items_old_and_no_order():
c = cart(updated_time="2026-05-01T00:00:00+00:00", physical=3)
result = classify_stale_cart(c, False, NOW, stale_days=30)
assert result == {"isStale": True, "reason": "abandoned_stale"}
def test_abandoned_stale_never_returned_for_recent_cart():
c = cart(updated_time="2026-07-01T00:00:00+00:00", physical=3)
result = classify_stale_cart(c, False, NOW, stale_days=30)
assert result["reason"] != "abandoned_stale"
assert result["isStale"] is False
def test_digital_and_gift_cert_items_count_toward_total():
c = cart(updated_time="2026-05-01T00:00:00+00:00", physical=0, digital=1)
result = classify_stale_cart(c, False, NOW, stale_days=30)
assert result == {"isStale": True, "reason": "abandoned_stale"}
def test_custom_stale_days_threshold_is_respected():
c = cart(updated_time="2026-07-05T00:00:00+00:00", physical=0)
result = classify_stale_cart(c, False, NOW, stale_days=3)
assert result == {"isStale": True, "reason": "empty_cart"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStaleCart } from "./clean-stale-carts.js";
const NOW = "2026-07-10T00:00:00Z";
const cart = ({ updatedTime = "2026-07-09T00:00:00Z", physical = 1, digital = 0, custom = 0, giftCert = 0 } = {}) => ({
id: "cart-1",
customerId: 42,
email: "buyer@example.com",
createdTime: updatedTime,
updatedTime,
lineItemCounts: { physical, digital, custom, giftCert },
});
test("active when recent and has items", () => {
const result = classifyStaleCart(cart({ updatedTime: "2026-07-09T00:00:00Z" }), false, NOW, 30);
assert.deepEqual(result, { isStale: false, reason: "active" });
});
test("empty cart past threshold is safe to delete", () => {
const c = cart({ updatedTime: "2026-05-01T00:00:00Z", physical: 0 });
const result = classifyStaleCart(c, false, NOW, 30);
assert.deepEqual(result, { isStale: true, reason: "empty_cart" });
});
test("empty but recent is active", () => {
const c = cart({ updatedTime: "2026-07-05T00:00:00Z", physical: 0 });
const result = classifyStaleCart(c, false, NOW, 30);
assert.equal(result.isStale, false);
});
test("converted duplicate regardless of age", () => {
const c = cart({ updatedTime: "2026-07-09T00:00:00Z", physical: 2 });
const result = classifyStaleCart(c, true, NOW, 30);
assert.deepEqual(result, { isStale: true, reason: "converted_duplicate" });
});
test("abandoned stale has items, old, and no order", () => {
const c = cart({ updatedTime: "2026-05-01T00:00:00Z", physical: 3 });
const result = classifyStaleCart(c, false, NOW, 30);
assert.deepEqual(result, { isStale: true, reason: "abandoned_stale" });
});
test("abandoned stale never returned for recent cart", () => {
const c = cart({ updatedTime: "2026-07-01T00:00:00Z", physical: 3 });
const result = classifyStaleCart(c, false, NOW, 30);
assert.notEqual(result.reason, "abandoned_stale");
assert.equal(result.isStale, false);
});
test("digital and gift cert items count toward total", () => {
const c = cart({ updatedTime: "2026-05-01T00:00:00Z", physical: 0, digital: 1 });
const result = classifyStaleCart(c, false, NOW, 30);
assert.deepEqual(result, { isStale: true, reason: "abandoned_stale" });
});
test("custom stale days threshold is respected", () => {
const c = cart({ updatedTime: "2026-07-05T00:00:00Z", physical: 0 });
const result = classifyStaleCart(c, false, NOW, 3);
assert.deepEqual(result, { isStale: true, reason: "empty_cart" });
});
Case studies
The dashboard that could not agree with itself
A home goods brand ran a marketing dashboard that pulled every cart to estimate abandonment rates. After three years live, the store had accumulated over eighty thousand cart records, most of them empty carts from bots and abandoned anonymous sessions that never had a single item added.
Running the classifier in dry run showed that seventy percent of those carts were empty_cart, safe to remove outright. After the cleanup, the dashboard's abandonment percentage suddenly looked believable again, since it was no longer averaged against tens of thousands of carts that were never really shopping sessions at all.
The API integration that slowed to a crawl
A headless storefront built on the Cart API created a new cart on every anonymous page load to support guest browsing. Within a year, an internal sync tool that paged GET /v3/carts nightly to reconcile promotions was taking hours and regularly timing out against store rate limits.
The team ran the cleanup script weekly. Most of the backlog classified as empty_cart, and a smaller set of converted_duplicate carts turned out to be sessions where checkout completed through a separate payment webhook. The nightly sync dropped back to minutes once the pile stopped growing unchecked.
After this runs on a schedule, the cart list stops growing without bound. Every cart that gets removed is either truly empty or a confirmed leftover from a completed sale, so nothing a shopper cares about ever disappears. Real abandoned carts with real contents stay exactly where they are, only tagged for a recovery workflow to reach out, until a human explicitly approves a later purge.
FAQ
Why do BigCommerce cart records keep piling up over time?
A BigCommerce V3 cart record never expires or self-deletes on the server. Once a guest checkout, a headless storefront session, or an app integration creates a cart, it persists indefinitely until it either converts to an order or is explicitly deleted through the API. The one hour abandoned-cart definition only triggers a recovery email, it never cleans up the underlying record.
Is it safe to bulk delete every old BigCommerce cart?
No. An old cart that still has real line items and no matching order may belong to a shopper who expects it to still be there when they come back. Only empty carts and carts that clearly duplicate a completed order are safe to hard delete. Carts with real contents and no confirmed order should be flagged for a human to review, not deleted automatically.
How do I check whether a stale cart actually turned into an order?
Cross-check the cart's customer_id or email against GET /v2/orders with a matching min_date_created window. If a matching order exists, the cart is an orphaned leftover from a checkout that completed through a different path, and it is safe to delete as a converted_duplicate rather than treated as a genuine abandoned cart.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Abandoned Carts. developer.bigcommerce.com rest-management abandoned-carts
- BigCommerce Developer Center: Recovering Abandoned Carts. developer.bigcommerce.com storefront headless abandoned-carts
- BigCommerce Support Community: Get a list of current abandoned cart tokens via API. support.bigcommerce.com abandoned-cart-tokens-via-api
On the solution:
- BigCommerce Developer Center: Carts (Management API Reference). developer.bigcommerce.com rest-management carts
- BigCommerce Developer Center: Carts Single (Get/Delete Cart). developer.bigcommerce.com rest-management carts carts-single
- BigCommerce Developer Center: Abandoned Carts Settings. developer.bigcommerce.com rest-management abandoned-carts-settings
Stuck on a tricky one?
If you have a problem in BigCommerce orders, catalog, 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 clear your cart backlog?
If this saved you a pile of manual clicks or a slow reporting dashboard, 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