Reconciler Customers
Duplicate customers for one email in BigCommerce
The same shopper checks out as a guest one week and creates an account the next, or gets imported twice from a POS with slightly different spacing in the email, and now their order history is split across two or three customer records. BigCommerce never notices and never merges them on its own. Here is why it happens and a small script that finds every cluster, reassigns the orders to one survivor, and only removes the duplicate once you have confirmed it is safe.
BigCommerce only enforces email uniqueness within the single customer record created through one path at a time. Guest checkout, storefront registration, admin creation, and V3 API imports are independent paths, so the same person can end up as a guest order with customer_id = 0, a real customer record, or two real customer records with matching emails, and nothing reconciles them automatically. Run a small Python or Node.js script that pulls every customer with GET /v3/customers, groups them by normalized email, cross-checks orphaned guest orders with GET /v2/orders?customer_id=0, picks the earliest account as the survivor, reassigns every order (including guest orders) onto it with PUT /v2/orders/{id}, and only deletes the duplicate with DELETE /v3/customers?id:in={id} after a human reviews the plan. Full code, tests, and a dry run guard are below.
The problem in plain words
An email address feels like it should be a single, stable identity in any store. But BigCommerce checks uniqueness only inside whichever path is creating the record right now. Guest checkout does not check the customer table at all, since it never creates a customer, it just writes the shopper's email onto the order's billing_address and leaves customer_id at 0. Storefront self-registration checks against other storefront registrations. Admin-panel manual creation and V3 API upserts each run their own check at write time.
None of these paths talk to each other. So the same person can pass through more than one of them and BigCommerce has no idea they are the same shopper. There is no background job that scans for matching emails and no automatic step that links a guest order to an account created afterward. The result is order history and loyalty data split across a customer_id = 0 guest order and one or more real customer ids, invisible until someone looks for it.
Why it happens
BigCommerce's email uniqueness rule is scoped, not global. A few common ways stores end up with duplicates anyway:
- A shopper checks out as a guest, which stores their email on
order.billing_address.emailand leavesorder.customer_idat 0. No customer record exists at all for that order. - That same shopper registers a real account later, or on a different device, and BigCommerce has no process that links the earlier guest order back to the new account.
- A POS or ERP import runs twice, or two integrations import the same shopper, without first checking for an existing email, creating two real customer ids for one person.
- A race condition or an OAuth or social login flow creates a second identity for someone who already had an account under the same email typed slightly differently, with different casing or stray whitespace.
This is a common source of confusion because nothing in the Admin surfaces existing duplicates automatically, and linking a guest order to an account is a manual control-panel action, not something the API does for you. See the citations at the end for the exact support threads and docs.
A duplicate customer cluster is not something a script should silently collapse. Deleting the wrong twin can strand loyalty points, saved addresses, wishlist data, or a storefront login the shopper still uses. So the safe pattern is detect, then plan a reassignment, then only delete the loser after every one of its orders is confirmed moved onto the survivor. Never fuzzy-match names to find duplicates. Only an exact match on the normalized email is safe enough to act on.
The fix, as a flow
We do not touch a customer record until we know exactly where every one of its orders needs to go. The job pulls every customer and every guest order, groups by normalized email, picks the earliest account per cluster as the survivor, reassigns every order (including orphaned guest orders matched by billing email) onto that survivor, and only after every reassignment is confirmed does it delete the duplicate customer id. Everything is gated behind DRY_RUN.
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. Give it read and write access to Customers and Orders, since the merge needs to read customers and orders, then write order reassignments and eventually delete a customer. 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 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 DRY_RUN="true" // start safe, change to false to write
Talk to the V2 orders and V3 customers APIs
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, raises on a bad status, and unwraps the data envelope that every V3 response wraps its payload in. V2 responses are plain arrays or objects.
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
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
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();
if (!text) return null;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
}
Pull every customer and every guest order
Page through GET /v3/customers?limit=250&page=N using meta.pagination.total_pages, collecting id, email, and date_created. Then page through GET /v2/orders?customer_id=0&limit=250 to find orphaned guest orders, reading each order's billing_address.email. Every guest order is already a candidate record with customer_id of 0, so the decision function treats it the same as any other order when it looks for a matching email.
def all_customers():
"""Yield every customer, paginated with meta.pagination.total_pages."""
page = 1
while True:
res = requests.get(
BASE + "v3/customers",
params={"limit": 250, "page": page},
headers={"X-Auth-Token": TOKEN, "Accept": "application/json"},
timeout=30,
)
res.raise_for_status()
body = res.json()
for row in body["data"]:
yield {"id": row["id"], "email": row["email"], "date_created": row["date_created"]}
if page >= body["meta"]["pagination"]["total_pages"]:
return
page += 1
def guest_orders():
"""Yield every order with customer_id 0, paginated."""
page = 1
while True:
batch = bc("GET", f"/v2/orders?customer_id=0&limit=250&page={page}")
if not batch:
return
for order in batch:
yield {
"id": order["id"],
"customer_id": order.get("customer_id", 0),
"billing_email": (order.get("billing_address") or {}).get("email", ""),
}
if len(batch) < 250:
return
page += 1
async function* allCustomers() {
let page = 1;
while (true) {
const res = await fetch(`${BASE}v3/customers?limit=250&page=${page}`, {
headers: { "X-Auth-Token": TOKEN, Accept: "application/json" },
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const body = await res.json();
for (const row of body.data) {
yield { id: row.id, email: row.email, date_created: row.date_created };
}
if (page >= body.meta.pagination.total_pages) return;
page += 1;
}
}
async function* guestOrders() {
let page = 1;
while (true) {
const batch = await bc("GET", `/v2/orders?customer_id=0&limit=250&page=${page}`);
if (!batch || !batch.length) return;
for (const order of batch) {
yield {
id: order.id,
customer_id: order.customer_id ?? 0,
billing_email: order.billing_address?.email || "",
};
}
if (batch.length < 250) return;
page += 1;
}
}
Plan the merge with one pure function
Keep the decision in its own function that takes fully-materialized customers and orders and returns a merge plan. It normalizes each email with trim and lowercase, groups customers by that key, and only produces a plan for groups with more than one customer. The survivor is the customer with the earliest date_created, tie-broken by the lowest id, which matches picking the original account over a later duplicate. It never fuzzy-matches names, since only an exact normalized email match is safe to act on.
def _normalize_email(email):
return (email or "").strip().lower()
def plan_customer_merge(customers, orders):
"""customers: [{id, email, date_created}], orders: [{id, customer_id, billing_email}]
-> [{survivorId, reassignOrderIds, deleteCustomerIds}]. Pure, no I/O."""
groups = {}
for customer in customers:
key = _normalize_email(customer["email"])
if not key:
continue
groups.setdefault(key, []).append(customer)
plans = []
for email, group in groups.items():
if len(group) < 2:
continue
survivor = min(group, key=lambda c: (c["date_created"], c["id"]))
losing_ids = {c["id"] for c in group if c["id"] != survivor["id"]}
reassign_order_ids = []
for order in orders:
if _normalize_email(order.get("billing_email")) != email:
continue
if order["customer_id"] == 0 or order["customer_id"] in losing_ids:
reassign_order_ids.append(order["id"])
plans.append({
"survivorId": survivor["id"],
"reassignOrderIds": sorted(reassign_order_ids),
"deleteCustomerIds": sorted(losing_ids),
})
plans.sort(key=lambda p: p["survivorId"])
return plans
function normalizeEmail(email) {
return (email || "").trim().toLowerCase();
}
export function planCustomerMerge(customers, orders) {
const groups = new Map();
for (const customer of customers) {
const key = normalizeEmail(customer.email);
if (!key) continue;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(customer);
}
const plans = [];
for (const [email, group] of groups) {
if (group.length < 2) continue;
const survivor = group.reduce((best, c) =>
c.date_created < best.date_created ||
(c.date_created === best.date_created && c.id < best.id) ? c : best
);
const losingIds = new Set(group.filter((c) => c.id !== survivor.id).map((c) => c.id));
const reassignOrderIds = [];
for (const order of orders) {
if (normalizeEmail(order.billing_email) !== email) continue;
if (order.customer_id === 0 || losingIds.has(order.customer_id)) {
reassignOrderIds.push(order.id);
}
}
plans.push({
survivorId: survivor.id,
reassignOrderIds: reassignOrderIds.sort((a, b) => a - b),
deleteCustomerIds: [...losingIds].sort((a, b) => a - b),
});
}
plans.sort((a, b) => a.survivorId - b.survivorId);
return plans;
}
Reassign orders, then confirm, then delete
For every order id in the plan, PUT /v2/orders/{order_id} with {"customer_id": survivor_id}, which works the same whether the order was a real duplicate's or an orphaned guest order at 0. Only after re-fetching GET /v2/orders?customer_id={loser_id} and confirming it returns zero rows do we call DELETE /v3/customers?id:in={loser_id}. Every write stays behind DRY_RUN, and the delete step is meant for a human to approve rather than run unattended.
def reassign_order(order_id, survivor_id):
return bc("PUT", f"/v2/orders/{order_id}", json={"customer_id": survivor_id})
def loser_orders_remaining(loser_id):
remaining = bc("GET", f"/v2/orders?customer_id={loser_id}&limit=1")
return len(remaining or [])
def delete_customer(customer_id):
return bc("DELETE", f"/v3/customers?id:in={customer_id}")
async function reassignOrder(orderId, survivorId) {
return bc("PUT", `/v2/orders/${orderId}`, { customer_id: survivorId });
}
async function loserOrdersRemaining(loserId) {
const remaining = await bc("GET", `/v2/orders?customer_id=${loserId}&limit=1`);
return (remaining || []).length;
}
async function deleteCustomer(customerId) {
return bc("DELETE", `/v3/customers?id:in=${customerId}`);
}
Wire it together with a dry run guard
The loop pulls customers and guest orders, builds the merge plan, and for each cluster reassigns every order, re-checks that the loser's orders are gone, then deletes the loser, all skipped when DRY_RUN is true. On the first few runs, leave DRY_RUN on so it only prints the plan: survivor id, orders it would reassign, and ids it would delete. Review that against the Admin, then switch it off. Run it on a schedule that matches how often you import or sync customers, for example nightly.
Always start with DRY_RUN=true. Before you let it delete anything, pull each losing customer's addresses with GET /v3/customers/{id}/addresses and attribute values with GET /v3/customers/attribute-values?customer_id:in={id}, and copy anything worth keeping onto the survivor first. Deleting a customer id can strand loyalty points, saved payment tokens, or a storefront login, so treat the delete step as something a human approves, not something that runs unattended.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs the merge plan, respects the dry run flag, and is safe to run again and again because a cluster whose orders are already reassigned and whose duplicate is already gone simply produces nothing left to do.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and safely merge duplicate BigCommerce customers that share one email.
BigCommerce enforces email uniqueness only within the customer record created
through a single path at a time: guest checkout (which stores the email on
order.billing_address and leaves order.customer_id at 0, meaning no customer
record exists), storefront self-registration, admin-panel manual creation, and
V3 Customers API upserts. Nothing reconciles a guest order to a later account,
and nothing merges two customer objects that share an email. The result is
order history split across customer_id 0 and one or more real customer ids.
This pulls every customer with GET /v3/customers, pulls every orphaned guest
order with GET /v2/orders?customer_id=0, groups customers by normalized email
with a pure function, picks the earliest account per cluster as the survivor,
reassigns every matching order (including guest orders) onto the survivor with
PUT /v2/orders/{id}, confirms the loser has zero orders left, then deletes the
duplicate with DELETE /v3/customers?id:in={id}. Every write is guarded by
DRY_RUN. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/duplicate-customers-for-one-email/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("merge_duplicate_customers")
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"
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
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
def _normalize_email(email):
return (email or "").strip().lower()
def plan_customer_merge(customers, orders):
"""customers: [{id, email, date_created}], orders: [{id, customer_id, billing_email}]
-> [{survivorId, reassignOrderIds, deleteCustomerIds}]. Pure, no I/O.
Groups customers by normalized email (trim + lowercase). Groups of size 1
produce no plan. For groups of size > 1, the survivor is the customer with
the earliest date_created, tied-broken by the lowest id. Then it scans
orders for any order whose customer_id is 0 or a non-surviving id in the
group, and whose normalized billing_email matches the group's email,
collecting those order ids to reassign. deleteCustomerIds is every id in
the group except the survivor. Never fuzzy-matches names.
"""
groups = {}
for customer in customers:
key = _normalize_email(customer["email"])
if not key:
continue
groups.setdefault(key, []).append(customer)
plans = []
for email, group in groups.items():
if len(group) < 2:
continue
survivor = min(group, key=lambda c: (c["date_created"], c["id"]))
losing_ids = {c["id"] for c in group if c["id"] != survivor["id"]}
reassign_order_ids = []
for order in orders:
if _normalize_email(order.get("billing_email")) != email:
continue
if order["customer_id"] == 0 or order["customer_id"] in losing_ids:
reassign_order_ids.append(order["id"])
plans.append({
"survivorId": survivor["id"],
"reassignOrderIds": sorted(reassign_order_ids),
"deleteCustomerIds": sorted(losing_ids),
})
plans.sort(key=lambda p: p["survivorId"])
return plans
def all_customers():
"""Yield every customer, paginated with meta.pagination.total_pages."""
page = 1
while True:
res = requests.get(
BASE + "v3/customers",
params={"limit": 250, "page": page},
headers={"X-Auth-Token": TOKEN, "Accept": "application/json"},
timeout=30,
)
res.raise_for_status()
body = res.json()
for row in body["data"]:
yield {"id": row["id"], "email": row["email"], "date_created": row["date_created"]}
if page >= body["meta"]["pagination"]["total_pages"]:
return
page += 1
def guest_orders():
"""Yield every order with customer_id 0, paginated."""
page = 1
while True:
batch = bc("GET", f"/v2/orders?customer_id=0&limit=250&page={page}")
if not batch:
return
for order in batch:
yield {
"id": order["id"],
"customer_id": order.get("customer_id", 0),
"billing_email": (order.get("billing_address") or {}).get("email", ""),
}
if len(batch) < 250:
return
page += 1
def reassign_order(order_id, survivor_id):
return bc("PUT", f"/v2/orders/{order_id}", json={"customer_id": survivor_id})
def loser_orders_remaining(loser_id):
remaining = bc("GET", f"/v2/orders?customer_id={loser_id}&limit=1")
return len(remaining or [])
def delete_customer(customer_id):
return bc("DELETE", f"/v3/customers?id:in={customer_id}")
def run():
customers = list(all_customers())
orders = list(guest_orders())
plans = plan_customer_merge(customers, orders)
merged = 0
for plan in plans:
log.warning(
"Cluster survivor=%s reassign=%s delete=%s. %s",
plan["survivorId"], plan["reassignOrderIds"], plan["deleteCustomerIds"],
"would merge" if DRY_RUN else "merging",
)
if DRY_RUN:
merged += 1
continue
for order_id in plan["reassignOrderIds"]:
reassign_order(order_id, plan["survivorId"])
for loser_id in plan["deleteCustomerIds"]:
if loser_orders_remaining(loser_id) > 0:
log.error("Skipping delete of %s, orders still remain.", loser_id)
continue
delete_customer(loser_id)
merged += 1
log.info("Done. %d cluster(s) %s.", merged, "to merge" if DRY_RUN else "merged")
if __name__ == "__main__":
run()
/**
* Find and safely merge duplicate BigCommerce customers that share one email.
*
* BigCommerce enforces email uniqueness only within the customer record created
* through a single path at a time: guest checkout (which stores the email on
* order.billing_address and leaves order.customer_id at 0, meaning no customer
* record exists), storefront self-registration, admin-panel manual creation, and
* V3 Customers API upserts. Nothing reconciles a guest order to a later account,
* and nothing merges two customer objects that share an email.
*
* This pulls every customer, pulls every orphaned guest order, groups customers
* by normalized email with a pure function, picks the earliest account per
* cluster as the survivor, reassigns every matching order (including guest
* orders) onto the survivor with PUT /v2/orders/{id}, confirms the loser has
* zero orders left, then deletes the duplicate. Guarded by DRY_RUN. Safe to run
* again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/duplicate-customers-for-one-email/
*/
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";
function normalizeEmail(email) {
return (email || "").trim().toLowerCase();
}
export function planCustomerMerge(customers, orders) {
const groups = new Map();
for (const customer of customers) {
const key = normalizeEmail(customer.email);
if (!key) continue;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(customer);
}
const plans = [];
for (const [email, group] of groups) {
if (group.length < 2) continue;
const survivor = group.reduce((best, c) =>
c.date_created < best.date_created ||
(c.date_created === best.date_created && c.id < best.id) ? c : best
);
const losingIds = new Set(group.filter((c) => c.id !== survivor.id).map((c) => c.id));
const reassignOrderIds = [];
for (const order of orders) {
if (normalizeEmail(order.billing_email) !== email) continue;
if (order.customer_id === 0 || losingIds.has(order.customer_id)) {
reassignOrderIds.push(order.id);
}
}
plans.push({
survivorId: survivor.id,
reassignOrderIds: reassignOrderIds.sort((a, b) => a - b),
deleteCustomerIds: [...losingIds].sort((a, b) => a - b),
});
}
plans.sort((a, b) => a.survivorId - b.survivorId);
return plans;
}
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();
if (!text) return null;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
}
async function* allCustomers() {
let page = 1;
while (true) {
const res = await fetch(`${BASE}v3/customers?limit=250&page=${page}`, {
headers: { "X-Auth-Token": TOKEN, Accept: "application/json" },
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const body = await res.json();
for (const row of body.data) {
yield { id: row.id, email: row.email, date_created: row.date_created };
}
if (page >= body.meta.pagination.total_pages) return;
page += 1;
}
}
async function* guestOrders() {
let page = 1;
while (true) {
const batch = await bc("GET", `/v2/orders?customer_id=0&limit=250&page=${page}`);
if (!batch || !batch.length) return;
for (const order of batch) {
yield {
id: order.id,
customer_id: order.customer_id ?? 0,
billing_email: order.billing_address?.email || "",
};
}
if (batch.length < 250) return;
page += 1;
}
}
async function reassignOrder(orderId, survivorId) {
return bc("PUT", `/v2/orders/${orderId}`, { customer_id: survivorId });
}
async function loserOrdersRemaining(loserId) {
const remaining = await bc("GET", `/v2/orders?customer_id=${loserId}&limit=1`);
return (remaining || []).length;
}
async function deleteCustomer(customerId) {
return bc("DELETE", `/v3/customers?id:in=${customerId}`);
}
export async function run() {
const customers = [];
for await (const customer of allCustomers()) customers.push(customer);
const orders = [];
for await (const order of guestOrders()) orders.push(order);
const plans = planCustomerMerge(customers, orders);
let merged = 0;
for (const plan of plans) {
console.warn(
`Cluster survivor=${plan.survivorId} reassign=${JSON.stringify(plan.reassignOrderIds)} delete=${JSON.stringify(plan.deleteCustomerIds)}. ${DRY_RUN ? "would merge" : "merging"}`
);
if (DRY_RUN) {
merged++;
continue;
}
for (const orderId of plan.reassignOrderIds) {
await reassignOrder(orderId, plan.survivorId);
}
for (const loserId of plan.deleteCustomerIds) {
if ((await loserOrdersRemaining(loserId)) > 0) {
console.error(`Skipping delete of ${loserId}, orders still remain.`);
continue;
}
await deleteCustomer(loserId);
}
merged++;
}
console.log(`Done. ${merged} cluster(s) ${DRY_RUN ? "to merge" : "merged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The planning rule is the part most worth testing, because it decides who survives, what gets reassigned, and what gets deleted. Because we kept plan_customer_merge pure, the test needs no network and no BigCommerce store. It just feeds in plain customers and orders and checks the plan.
from merge_duplicate_customers import plan_customer_merge
def customer(id, email, date_created):
return {"id": id, "email": email, "date_created": date_created}
def order(id, customer_id, billing_email):
return {"id": id, "customer_id": customer_id, "billing_email": billing_email}
def test_no_plan_when_all_emails_unique():
customers = [customer(1, "a@example.com", "2026-01-01"), customer(2, "b@example.com", "2026-01-01")]
assert plan_customer_merge(customers, []) == []
def test_survivor_is_earliest_date_created():
customers = [
customer(2, "shopper@example.com", "2026-02-01"),
customer(1, "shopper@example.com", "2026-01-01"),
]
plans = plan_customer_merge(customers, [])
assert plans == [{"survivorId": 1, "reassignOrderIds": [], "deleteCustomerIds": [2]}]
def test_tie_break_on_lowest_id_when_dates_equal():
customers = [
customer(5, "shopper@example.com", "2026-01-01"),
customer(2, "shopper@example.com", "2026-01-01"),
]
plans = plan_customer_merge(customers, [])
assert plans[0]["survivorId"] == 2
assert plans[0]["deleteCustomerIds"] == [5]
def test_matches_email_case_and_whitespace_insensitive():
customers = [
customer(1, "Shopper@Example.com", "2026-01-01"),
customer(2, " shopper@example.com ", "2026-01-02"),
]
plans = plan_customer_merge(customers, [])
assert plans == [{"survivorId": 1, "reassignOrderIds": [], "deleteCustomerIds": [2]}]
def test_reassigns_guest_orders_at_customer_id_zero():
customers = [customer(1, "shopper@example.com", "2026-01-01")]
orders = [order(100, 0, "shopper@example.com"), order(101, 0, "someone.else@example.com")]
plans = plan_customer_merge(customers, orders)
# single customer, no duplicate cluster, so no plan even though a guest order matches
assert plans == []
def test_reassigns_losing_customer_and_guest_orders_onto_survivor():
customers = [
customer(1, "shopper@example.com", "2026-01-01"),
customer(2, "shopper@example.com", "2026-01-05"),
]
orders = [
order(100, 0, "shopper@example.com"),
order(101, 2, "shopper@example.com"),
order(102, 1, "shopper@example.com"),
order(103, 9, "someone.else@example.com"),
]
plans = plan_customer_merge(customers, orders)
assert plans == [{"survivorId": 1, "reassignOrderIds": [100, 101], "deleteCustomerIds": [2]}]
def test_ignores_customers_without_an_email():
customers = [customer(1, "", "2026-01-01"), customer(2, "", "2026-01-01")]
assert plan_customer_merge(customers, []) == []
def test_never_fuzzy_matches_similar_but_different_emails():
customers = [customer(1, "shopper@example.com", "2026-01-01"), customer(2, "shoppers@example.com", "2026-01-01")]
assert plan_customer_merge(customers, []) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { planCustomerMerge } from "./merge-duplicate-customers.js";
const customer = (id, email, date_created) => ({ id, email, date_created });
const order = (id, customer_id, billing_email) => ({ id, customer_id, billing_email });
test("no plan when all emails unique", () => {
const customers = [customer(1, "a@example.com", "2026-01-01"), customer(2, "b@example.com", "2026-01-01")];
assert.deepEqual(planCustomerMerge(customers, []), []);
});
test("survivor is earliest date_created", () => {
const customers = [
customer(2, "shopper@example.com", "2026-02-01"),
customer(1, "shopper@example.com", "2026-01-01"),
];
const plans = planCustomerMerge(customers, []);
assert.deepEqual(plans, [{ survivorId: 1, reassignOrderIds: [], deleteCustomerIds: [2] }]);
});
test("tie break on lowest id when dates equal", () => {
const customers = [
customer(5, "shopper@example.com", "2026-01-01"),
customer(2, "shopper@example.com", "2026-01-01"),
];
const plans = planCustomerMerge(customers, []);
assert.equal(plans[0].survivorId, 2);
assert.deepEqual(plans[0].deleteCustomerIds, [5]);
});
test("matches email case and whitespace insensitive", () => {
const customers = [
customer(1, "Shopper@Example.com", "2026-01-01"),
customer(2, " shopper@example.com ", "2026-01-02"),
];
const plans = planCustomerMerge(customers, []);
assert.deepEqual(plans, [{ survivorId: 1, reassignOrderIds: [], deleteCustomerIds: [2] }]);
});
test("single customer with a matching guest order produces no plan", () => {
const customers = [customer(1, "shopper@example.com", "2026-01-01")];
const orders = [order(100, 0, "shopper@example.com"), order(101, 0, "someone.else@example.com")];
assert.deepEqual(planCustomerMerge(customers, orders), []);
});
test("reassigns losing customer and guest orders onto survivor", () => {
const customers = [
customer(1, "shopper@example.com", "2026-01-01"),
customer(2, "shopper@example.com", "2026-01-05"),
];
const orders = [
order(100, 0, "shopper@example.com"),
order(101, 2, "shopper@example.com"),
order(102, 1, "shopper@example.com"),
order(103, 9, "someone.else@example.com"),
];
const plans = planCustomerMerge(customers, orders);
assert.deepEqual(plans, [{ survivorId: 1, reassignOrderIds: [100, 101], deleteCustomerIds: [2] }]);
});
test("ignores customers without an email", () => {
const customers = [customer(1, "", "2026-01-01"), customer(2, "", "2026-01-01")];
assert.deepEqual(planCustomerMerge(customers, []), []);
});
test("never fuzzy matches similar but different emails", () => {
const customers = [customer(1, "shopper@example.com", "2026-01-01"), customer(2, "shoppers@example.com", "2026-01-01")];
assert.deepEqual(planCustomerMerge(customers, []), []);
});
Case studies
The regular who kept checking out as a stranger
A skincare brand had a shopper who bought as a guest three times before finally creating an account. Every guest order sat under customer_id = 0 with only the billing email tying it to that person, and the storefront account showed just one order, making the customer look far less loyal than she actually was.
The nightly job matched her guest orders by billing email against her registered account, reassigned all three onto the survivor, and her order history finally lined up in one place. Support stopped having to manually search orders by email every time she wrote in.
The retailer with two ids for every regular
A retailer syncing customers from an in-store POS system had an import script that never checked for an existing email before creating a new customer. Regulars who shopped both online and in-store ended up as two BigCommerce customer records, splitting their purchase history and locking them out of a loyalty threshold they had actually already crossed.
Running the script in dry run surfaced every cluster with the exact orders that would move. The team reviewed the plan, confirmed the addresses on the losing records were not needed, and let it run for real. Loyalty totals matched reality again the same week.
After this runs on a schedule, one shopper is one customer record with one complete order history, whether they started as a guest, registered later, or got imported twice. Nothing gets merged on a guess. The script only ever acts on an exact normalized email match, only deletes a duplicate once its orders are confirmed moved, and leaves every ambiguous case for a human to review.
FAQ
Why does BigCommerce let the same person end up as two customers?
BigCommerce creates customer-like records through several independent paths: guest checkout, storefront self-registration, admin-panel manual creation, and app or import upserts through the V3 Customers API. Email uniqueness is only enforced within the record type created by a single path, and there is no automatic process that reconciles a guest order to a later account or merges two customer objects that share an email.
Why does a guest order not show up under the customer's account?
A guest checkout stores the shopper's email on the order's billing_address but leaves order.customer_id at 0, meaning no customer record was created at all. If that same person registers an account later, BigCommerce never links the two, so the guest order stays orphaned under customer_id 0 unless someone attaches it by hand in the control panel.
Can a script safely merge two BigCommerce customers automatically?
Not by deleting on its own. The safe pattern is a script that reassigns every order from the losing customer_id, including guest orders matched by billing email, onto a chosen survivor, confirms the reassignment, and only then deletes the duplicate, gated behind a DRY_RUN flag so a human reviews the merge plan first. Deleting the wrong twin can strand loyalty points, stored addresses, or a storefront login.
Related field notes
Citations
On the problem:
- BigCommerce Support: duplicate customers are getting created in BigCommerce with the same email id. support.bigcommerce.com duplicate customers same email id
- BigCommerce Support: multiple customer accounts, with same email. support.bigcommerce.com multiple customer accounts with same email
- BigCommerce Support: linking a guest order to a customer's profile. support.bigcommerce.com linking guest order to customers profile
On the solution:
- BigCommerce Developer Center: Customers V3, the REST Management API reference. developer.bigcommerce.com rest-management customers
- BigCommerce Developer Center: Customers V2, the REST Management API reference. developer.bigcommerce.com rest-management customers-v2
- BigCommerce Developer Center: Orders Overview, the V2 Orders REST reference including customer_id and billing_address. developer.bigcommerce.com store-operations orders
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 clean up your customer list?
If this saved you a pile of manual clicks or a support ticket about missing order history, 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