Repair Customers
No API endpoint to merge two customer records
The same shopper checks out as a guest, then registers later with a slightly different email casing, and BigCommerce gives you two completely separate customer_id's. Their orders, addresses, and store credit are split across records that the admin UI shows as unrelated people, and there is no merge button and no merge endpoint anywhere in the REST Management API. Here is why that gap exists and a script that safely consolidates the duplicate into one canonical customer.
BigCommerce customer records created by guest checkout, storefront registration, and admin-panel entry are each a fully independent entity, with orders owned through order.customer_id and addresses owned through address.customer_id. There is no merge or alias relationship in the data model, and the REST Management API only exposes CRUD on individual resources, so BigCommerce never shipped a merge endpoint. Instead, find duplicate profiles by clustering on normalized email with GET /v3/customers?email:in={email}, pick a canonical customer_id per cluster, then reassign every order from the losing id with PUT /v2/orders/{order_id} setting {"customer_id": canonical_id}, recreate any missing addresses on the canonical id with POST /v3/customers/addresses, and only flag the losing customer_id for human confirmation before deletion, never delete it automatically. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce never asks whether a shopper already has an account. A guest checkout creates its own customer record. Registering on the storefront afterward creates another. Someone entering the customer by hand in the admin panel creates a third. Each one gets its own customer_id, and BigCommerce treats that id as a completely separate entity from that moment on.
Orders point at a customer through a foreign key, order.customer_id. Addresses point at a customer the same way, through address.customer_id. Nothing in the schema says "these two customer_id's are actually the same person." So when a shopper checks out as a guest with one email, then registers later with a slightly different casing of that email, or uses a work address on one order and a home address on another, BigCommerce quietly builds up two, three, or more customer records that the storefront and the admin UI both display as unrelated strangers.
Why it happens
This is a data model gap, not a bug in any single request. A few common ways one shopper ends up with multiple customer_id's:
- A shopper checks out as a guest, generating a customer record, then later creates a real account on the storefront with an email that differs only in casing or a typo, generating a second, unrelated record.
- Store staff enter a customer by hand in the admin panel to place a phone or email order, duplicating a shopper who already has a storefront account under a slightly different email or name spelling.
- The REST Management API (V2 Orders, V3 Customers) only exposes CRUD on individual resources. There is no bulk "reassign all child resources from customer A to customer B" call, so even when a duplicate is spotted, there is no single request that fixes it.
- Because orders and addresses are owned by exactly one customer_id with no alias table, the platform has no place to record that two id's represent the same person, even if someone wanted to flag it.
This is a recurring, unanswered request on BigCommerce's own community support forum, where merchants repeatedly ask how to merge two customer accounts and are told there is no built-in way to do it. See the citations at the end for the exact threads and docs.
There is no atomic merge call to wait for, request, or work around with a webhook. The fix has to be a reviewable batch of ordinary REST writes: reassign every order's customer_id to the canonical id, recreate any address on the canonical id that does not already exist there, and never delete the losing customer_id automatically. Deleting a customer_id that still has an order pointing to it, for example because a reassignment partially failed, corrupts reporting integrity, so that last step always waits for a human.
The fix, as a flow
We do not touch checkout, registration, or the admin panel. We add a script that clusters customers by normalized email, picks a canonical customer_id per cluster, reassigns the duplicate's orders and migrates its addresses, and flags the duplicate for a human to confirm before anyone deletes it.
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 Customers (modify) and Orders (modify) scope so it can read and reassign both. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_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="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" // start safe, change to false to write
Talk to the V2 Orders and V3 Customers REST APIs
Customers and addresses live under https://api.bigcommerce.com/stores/{store_hash}/v3/, wrapped in {data, meta.pagination}. Orders live under the v2/ base and return plain arrays or objects. A small helper handles GET, PUT, and POST, and raises on a non-2xx response.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(base, path, params=None):
r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def bc_put(base, path, body):
r = requests.put(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def bc_post(base, path, body):
r = requests.post(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(base, path, params = {}) {
const url = new URL(`${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 bcPut(base, path, body) {
const res = await fetch(`${base}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPost(base, path, body) {
const res = await fetch(`${base}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
Find the duplicate customer clusters
Page through every customer with GET /v3/customers?limit=250&page=n, normalize and lowercase each email, and group customer_id's that share a normalized email into a cluster. Within each cluster, pick the canonical customer_id, for example the lowest id or the earliest date_created, and treat the rest as duplicates.
def all_customers():
page = 1
while True:
resp = bc_get(API_BASE_V3, "/customers", {"limit": 250, "page": page})
rows = resp.get("data", [])
if not rows:
return
for row in rows:
yield row
page += 1
def cluster_by_email(customers):
clusters = {}
for c in customers:
key = (c.get("email") or "").strip().lower()
if not key:
continue
clusters.setdefault(key, []).append(c)
return {k: v for k, v in clusters.items() if len(v) > 1}
async function* allCustomers() {
let page = 1;
while (true) {
const resp = await bcGet(API_BASE_V3, "/customers", { limit: 250, page });
const rows = resp.data || [];
if (!rows.length) return;
for (const row of rows) yield row;
page += 1;
}
}
function clusterByEmail(customers) {
const clusters = new Map();
for (const c of customers) {
const key = (c.email || "").trim().toLowerCase();
if (!key) continue;
if (!clusters.has(key)) clusters.set(key, []);
clusters.get(key).push(c);
}
for (const [key, rows] of clusters) {
if (rows.length <= 1) clusters.delete(key);
}
return clusters;
}
Decide the merge plan with one pure function
Keep the decision in its own function that takes the canonical customer's addresses, and the duplicate's orders and addresses, and returns the plan. Every order id in the duplicate's history is reassigned, regardless of status_id, since Refunded and Cancelled orders still belong in the shopper's history. Each address is compared against the canonical customer's addresses by a normalized key, address1, postal_code, and city, lowercased, so a true duplicate address is skipped and a genuinely new one is recreated. The duplicate customer_id is always the one flagged, and the function asserts it never equals the canonical id before returning.
def _address_key(address):
return (
(address.get("address1") or "").strip().lower(),
(address.get("postal_code") or "").strip().lower(),
(address.get("city") or "").strip().lower(),
)
def plan_customer_merge(canonical, duplicate):
canonical_keys = {_address_key(a) for a in canonical.get("addresses", [])}
orders_to_reassign = [o["id"] for o in duplicate.get("orders", [])]
addresses_to_create = []
addresses_to_skip = []
for address in duplicate.get("addresses", []):
if _address_key(address) in canonical_keys:
addresses_to_skip.append(address["id"])
else:
addresses_to_create.append(address)
duplicate_customer_id_to_deactivate = duplicate["id"]
assert duplicate_customer_id_to_deactivate != canonical["id"], (
"duplicate customer_id must never equal canonical customer_id"
)
return {
"ordersToReassign": orders_to_reassign,
"addressesToCreate": addresses_to_create,
"addressesToSkip": addresses_to_skip,
"duplicateCustomerIdToDeactivate": duplicate_customer_id_to_deactivate,
}
function addressKey(address) {
return [
(address.address1 || "").trim().toLowerCase(),
(address.postal_code || "").trim().toLowerCase(),
(address.city || "").trim().toLowerCase(),
].join("|");
}
export function planCustomerMerge(canonical, duplicate) {
const canonicalKeys = new Set((canonical.addresses || []).map(addressKey));
const ordersToReassign = (duplicate.orders || []).map((o) => o.id);
const addressesToCreate = [];
const addressesToSkip = [];
for (const address of duplicate.addresses || []) {
if (canonicalKeys.has(addressKey(address))) {
addressesToSkip.push(address.id);
} else {
addressesToCreate.push(address);
}
}
const duplicateCustomerIdToDeactivate = duplicate.id;
if (duplicateCustomerIdToDeactivate === canonical.id) {
throw new Error("duplicate customer_id must never equal canonical customer_id");
}
return {
ordersToReassign,
addressesToCreate,
addressesToSkip,
duplicateCustomerIdToDeactivate,
};
}
Apply the plan: reassign orders, recreate addresses
For every id in ordersToReassign, call PUT /v2/orders/{order_id} with {"customer_id": canonical_id}. V2 order PUT behaves like a partial update, so only customer_id needs to be sent. For every address in addressesToCreate, call POST /v3/customers/addresses with an array containing customer_id set to the canonical id and the rest of the address fields, since addresses are owned by exactly one customer_id and there is no move-address call.
def reassign_order(order_id, canonical_id):
return bc_put(API_BASE_V2, f"/orders/{order_id}", {"customer_id": canonical_id})
def create_address(canonical_id, address):
payload = [{
"customer_id": canonical_id,
"first_name": address.get("first_name", ""),
"last_name": address.get("last_name", ""),
"address1": address.get("address1", ""),
"city": address.get("city", ""),
"state_or_province": address.get("state_or_province", ""),
"postal_code": address.get("postal_code", ""),
"country_code": address.get("country_code", ""),
}]
return bc_post(API_BASE_V3, "/customers/addresses", payload)
async function reassignOrder(orderId, canonicalId) {
return bcPut(API_BASE_V2, `/orders/${orderId}`, { customer_id: canonicalId });
}
async function createAddress(canonicalId, address) {
const payload = [{
customer_id: canonicalId,
first_name: address.first_name || "",
last_name: address.last_name || "",
address1: address.address1 || "",
city: address.city || "",
state_or_province: address.state_or_province || "",
postal_code: address.postal_code || "",
country_code: address.country_code || "",
}];
return bcPost(API_BASE_V3, "/customers/addresses", payload);
}
Wire it together with a dry run guard, never auto-delete
The loop clusters customers, plans each merge, and applies it. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the planned PUT/POST calls, order ids, address payloads, and the target canonical_id, without executing any of them. It never calls DELETE /v3/customers?id:in={losing_id} on its own. The duplicate is only flagged in the log for a human to confirm, since removing a customer_id that still has an order pointing at it, for example if a reassignment call failed partway through, corrupts reporting integrity.
Always start with DRY_RUN=true, and never let the job call DELETE on a duplicate customer_id automatically. Confirm every reassigned order and recreated address landed on the canonical customer first, then delete the duplicate by hand once you are certain nothing still points at it.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only reassigns orders and recreates addresses, and only flags, never deletes, the duplicate customer.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Consolidate duplicate BigCommerce customer records onto one canonical customer_id.
BigCommerce creates a fully independent customer record for guest checkout,
storefront registration, and admin-panel entry, and treats each customer_id as
its own entity with orders owned through order.customer_id and addresses owned
through address.customer_id. There is no merge or alias relationship in the
data model, and the REST Management API only exposes CRUD on individual
resources, never a bulk reassign-all-child-resources call, so BigCommerce never
shipped a merge endpoint. This job clusters customers by normalized email,
picks a canonical customer_id per cluster, reassigns every order from the
duplicate to the canonical id, recreates any address on the canonical id that
does not already exist there, and flags the duplicate customer_id for human
confirmation. It never deletes a customer record on its own. Safe to run again
and again.
Guide: https://www.allanninal.dev/bigcommerce/no-customer-merge-endpoint/
"""
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"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(base, path, params=None):
r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def bc_put(base, path, body):
r = requests.put(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def bc_post(base, path, body):
r = requests.post(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def _address_key(address):
return (
(address.get("address1") or "").strip().lower(),
(address.get("postal_code") or "").strip().lower(),
(address.get("city") or "").strip().lower(),
)
def plan_customer_merge(canonical, duplicate):
"""Pure decision. No network, no side effects.
Every order id under duplicate.orders is added to ordersToReassign,
regardless of status_id, so refunded and cancelled orders are preserved.
Each duplicate address is compared to the canonical customer's addresses
by a normalized (address1, postal_code, city) key: a match is skipped,
anything else is queued to be recreated. duplicateCustomerIdToDeactivate
is always duplicate["id"], asserted to never equal canonical["id"].
"""
canonical_keys = {_address_key(a) for a in canonical.get("addresses", [])}
orders_to_reassign = [o["id"] for o in duplicate.get("orders", [])]
addresses_to_create = []
addresses_to_skip = []
for address in duplicate.get("addresses", []):
if _address_key(address) in canonical_keys:
addresses_to_skip.append(address["id"])
else:
addresses_to_create.append(address)
duplicate_customer_id_to_deactivate = duplicate["id"]
assert duplicate_customer_id_to_deactivate != canonical["id"], (
"duplicate customer_id must never equal canonical customer_id"
)
return {
"ordersToReassign": orders_to_reassign,
"addressesToCreate": addresses_to_create,
"addressesToSkip": addresses_to_skip,
"duplicateCustomerIdToDeactivate": duplicate_customer_id_to_deactivate,
}
def all_customers():
page = 1
while True:
resp = bc_get(API_BASE_V3, "/customers", {"limit": 250, "page": page})
rows = resp.get("data", [])
if not rows:
return
for row in rows:
yield row
page += 1
def cluster_by_email(customers):
clusters = {}
for c in customers:
key = (c.get("email") or "").strip().lower()
if not key:
continue
clusters.setdefault(key, []).append(c)
return {k: v for k, v in clusters.items() if len(v) > 1}
def customer_orders(customer_id):
orders = []
page = 1
while True:
rows = bc_get(API_BASE_V2, "/orders", {"customer_id": customer_id, "limit": 250, "page": page})
if not rows:
return orders
orders.extend(rows)
page += 1
def customer_addresses(customer_id):
resp = bc_get(API_BASE_V3, "/customers/addresses", {"customer_id:in": customer_id})
return resp.get("data", [])
def reassign_order(order_id, canonical_id):
return bc_put(API_BASE_V2, f"/orders/{order_id}", {"customer_id": canonical_id})
def create_address(canonical_id, address):
payload = [{
"customer_id": canonical_id,
"first_name": address.get("first_name", ""),
"last_name": address.get("last_name", ""),
"address1": address.get("address1", ""),
"city": address.get("city", ""),
"state_or_province": address.get("state_or_province", ""),
"postal_code": address.get("postal_code", ""),
"country_code": address.get("country_code", ""),
}]
return bc_post(API_BASE_V3, "/customers/addresses", payload)
def pick_canonical(cluster):
return sorted(cluster, key=lambda c: c.get("id"))[0]
def run():
customers = list(all_customers())
clusters = cluster_by_email(customers)
merged = 0
flagged = 0
for email, members in clusters.items():
canonical_record = pick_canonical(members)
canonical_id = canonical_record["id"]
canonical = {"id": canonical_id, "addresses": customer_addresses(canonical_id)}
for member in members:
if member["id"] == canonical_id:
continue
duplicate = {
"id": member["id"],
"orders": customer_orders(member["id"]),
"addresses": customer_addresses(member["id"]),
}
plan = plan_customer_merge(canonical, duplicate)
log.info(
"email=%s canonical_id=%s duplicate_id=%s orders_to_reassign=%s "
"addresses_to_create=%d addresses_to_skip=%s (%s)",
email, canonical_id, plan["duplicateCustomerIdToDeactivate"],
plan["ordersToReassign"], len(plan["addressesToCreate"]),
plan["addressesToSkip"], "dry run" if DRY_RUN else "applying",
)
if not DRY_RUN:
for order_id in plan["ordersToReassign"]:
reassign_order(order_id, canonical_id)
for address in plan["addressesToCreate"]:
create_address(canonical_id, address)
log.warning(
"Duplicate customer_id %s flagged for human confirmation before deletion.",
plan["duplicateCustomerIdToDeactivate"],
)
merged += 1
flagged += 1
log.info(
"Done. %d duplicate(s) %s, %d duplicate(s) flagged for review.",
merged, "to merge" if DRY_RUN else "merged", flagged,
)
if __name__ == "__main__":
run()
/**
* Consolidate duplicate BigCommerce customer records onto one canonical customer_id.
*
* BigCommerce creates a fully independent customer record for guest checkout,
* storefront registration, and admin-panel entry, and treats each customer_id
* as its own entity with orders owned through order.customer_id and addresses
* owned through address.customer_id. There is no merge or alias relationship
* in the data model, and the REST Management API only exposes CRUD on
* individual resources, never a bulk reassign-all-child-resources call, so
* BigCommerce never shipped a merge endpoint. This job clusters customers by
* normalized email, picks a canonical customer_id per cluster, reassigns every
* order from the duplicate to the canonical id, recreates any address on the
* canonical id that does not already exist there, and flags the duplicate
* customer_id for human confirmation. It never deletes a customer record on
* its own.
*
* Guide: https://www.allanninal.dev/bigcommerce/no-customer-merge-endpoint/
*/
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_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
function addressKey(address) {
return [
(address.address1 || "").trim().toLowerCase(),
(address.postal_code || "").trim().toLowerCase(),
(address.city || "").trim().toLowerCase(),
].join("|");
}
/**
* Pure decision. No network, no side effects.
*
* Every order id under duplicate.orders is added to ordersToReassign,
* regardless of status_id, so refunded and cancelled orders are preserved.
* Each duplicate address is compared to the canonical customer's addresses
* by a normalized (address1, postal_code, city) key: a match is skipped,
* anything else is queued to be recreated. duplicateCustomerIdToDeactivate
* is always duplicate.id, asserted to never equal canonical.id.
*/
export function planCustomerMerge(canonical, duplicate) {
const canonicalKeys = new Set((canonical.addresses || []).map(addressKey));
const ordersToReassign = (duplicate.orders || []).map((o) => o.id);
const addressesToCreate = [];
const addressesToSkip = [];
for (const address of duplicate.addresses || []) {
if (canonicalKeys.has(addressKey(address))) {
addressesToSkip.push(address.id);
} else {
addressesToCreate.push(address);
}
}
const duplicateCustomerIdToDeactivate = duplicate.id;
if (duplicateCustomerIdToDeactivate === canonical.id) {
throw new Error("duplicate customer_id must never equal canonical customer_id");
}
return {
ordersToReassign,
addressesToCreate,
addressesToSkip,
duplicateCustomerIdToDeactivate,
};
}
async function bcGet(base, path, params = {}) {
const url = new URL(`${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 bcPut(base, path, body) {
const res = await fetch(`${base}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPost(base, path, body) {
const res = await fetch(`${base}${path}`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* allCustomers() {
let page = 1;
while (true) {
const resp = await bcGet(API_BASE_V3, "/customers", { limit: 250, page });
const rows = resp.data || [];
if (!rows.length) return;
for (const row of rows) yield row;
page += 1;
}
}
function clusterByEmail(customers) {
const clusters = new Map();
for (const c of customers) {
const key = (c.email || "").trim().toLowerCase();
if (!key) continue;
if (!clusters.has(key)) clusters.set(key, []);
clusters.get(key).push(c);
}
for (const [key, rows] of clusters) {
if (rows.length <= 1) clusters.delete(key);
}
return clusters;
}
async function customerOrders(customerId) {
const orders = [];
let page = 1;
while (true) {
const rows = await bcGet(API_BASE_V2, "/orders", { customer_id: customerId, limit: 250, page });
if (!rows.length) return orders;
orders.push(...rows);
page += 1;
}
}
async function customerAddresses(customerId) {
const resp = await bcGet(API_BASE_V3, "/customers/addresses", { "customer_id:in": customerId });
return resp.data || [];
}
async function reassignOrder(orderId, canonicalId) {
return bcPut(API_BASE_V2, `/orders/${orderId}`, { customer_id: canonicalId });
}
async function createAddress(canonicalId, address) {
const payload = [{
customer_id: canonicalId,
first_name: address.first_name || "",
last_name: address.last_name || "",
address1: address.address1 || "",
city: address.city || "",
state_or_province: address.state_or_province || "",
postal_code: address.postal_code || "",
country_code: address.country_code || "",
}];
return bcPost(API_BASE_V3, "/customers/addresses", payload);
}
function pickCanonical(cluster) {
return [...cluster].sort((a, b) => a.id - b.id)[0];
}
export async function run() {
const customers = [];
for await (const c of allCustomers()) customers.push(c);
const clusters = clusterByEmail(customers);
let merged = 0;
let flagged = 0;
for (const [email, members] of clusters) {
const canonicalRecord = pickCanonical(members);
const canonicalId = canonicalRecord.id;
const canonical = { id: canonicalId, addresses: await customerAddresses(canonicalId) };
for (const member of members) {
if (member.id === canonicalId) continue;
const duplicate = {
id: member.id,
orders: await customerOrders(member.id),
addresses: await customerAddresses(member.id),
};
const plan = planCustomerMerge(canonical, duplicate);
console.log(
`email=${email} canonical_id=${canonicalId} duplicate_id=${plan.duplicateCustomerIdToDeactivate} ` +
`orders_to_reassign=${JSON.stringify(plan.ordersToReassign)} ` +
`addresses_to_create=${plan.addressesToCreate.length} addresses_to_skip=${JSON.stringify(plan.addressesToSkip)} ` +
`(${DRY_RUN ? "dry run" : "applying"})`
);
if (!DRY_RUN) {
for (const orderId of plan.ordersToReassign) await reassignOrder(orderId, canonicalId);
for (const address of plan.addressesToCreate) await createAddress(canonicalId, address);
}
console.warn(`Duplicate customer_id ${plan.duplicateCustomerIdToDeactivate} flagged for human confirmation before deletion.`);
merged += 1;
flagged += 1;
}
}
console.log(`Done. ${merged} duplicate(s) ${DRY_RUN ? "to merge" : "merged"}, ${flagged} duplicate(s) flagged for review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which orders move and which addresses get recreated on the canonical customer. Because plan_customer_merge takes only plain values and returns a plain object, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the plan.
import pytest
from merge_duplicate_customers import plan_customer_merge
def address(id_=1, address1="123 Main St", postal_code="90210", city="Beverly Hills"):
return {"id": id_, "address1": address1, "postal_code": postal_code, "city": city}
def test_reassigns_every_order_regardless_of_status_id():
canonical = {"id": 1, "addresses": []}
duplicate = {
"id": 2,
"orders": [
{"id": 100, "status_id": 10, "total_inc_tax": "50.00"},
{"id": 101, "status_id": 4, "total_inc_tax": "20.00"},
{"id": 102, "status_id": 5, "total_inc_tax": "10.00"},
],
"addresses": [],
}
plan = plan_customer_merge(canonical, duplicate)
assert plan["ordersToReassign"] == [100, 101, 102]
def test_skips_duplicate_address_and_creates_new_one():
canonical = {"id": 1, "addresses": [address(id_=9)]}
duplicate = {
"id": 2,
"orders": [],
"addresses": [
address(id_=10, address1="123 Main St", postal_code="90210", city="Beverly Hills"),
address(id_=11, address1="456 Oak Ave", postal_code="10001", city="New York"),
],
}
plan = plan_customer_merge(canonical, duplicate)
assert plan["addressesToSkip"] == [10]
assert [a["id"] for a in plan["addressesToCreate"]] == [11]
def test_address_match_is_case_insensitive():
canonical = {"id": 1, "addresses": [address(id_=9, address1="123 MAIN ST", city="BEVERLY HILLS")]}
duplicate = {"id": 2, "orders": [], "addresses": [address(id_=10, address1="123 main st", city="beverly hills")]}
plan = plan_customer_merge(canonical, duplicate)
assert plan["addressesToSkip"] == [10]
assert plan["addressesToCreate"] == []
def test_duplicate_customer_id_to_deactivate_is_the_duplicate():
canonical = {"id": 1, "addresses": []}
duplicate = {"id": 2, "orders": [], "addresses": []}
plan = plan_customer_merge(canonical, duplicate)
assert plan["duplicateCustomerIdToDeactivate"] == 2
def test_asserts_duplicate_never_equals_canonical():
canonical = {"id": 5, "addresses": []}
duplicate = {"id": 5, "orders": [], "addresses": []}
with pytest.raises(AssertionError):
plan_customer_merge(canonical, duplicate)
import { test } from "node:test";
import assert from "node:assert/strict";
import { planCustomerMerge } from "./merge-duplicate-customers.js";
const makeAddress = ({ id = 1, address1 = "123 Main St", postal_code = "90210", city = "Beverly Hills" } = {}) => ({
id, address1, postal_code, city,
});
test("reassigns every order regardless of status_id", () => {
const canonical = { id: 1, addresses: [] };
const duplicate = {
id: 2,
orders: [
{ id: 100, status_id: 10, total_inc_tax: "50.00" },
{ id: 101, status_id: 4, total_inc_tax: "20.00" },
{ id: 102, status_id: 5, total_inc_tax: "10.00" },
],
addresses: [],
};
const plan = planCustomerMerge(canonical, duplicate);
assert.deepEqual(plan.ordersToReassign, [100, 101, 102]);
});
test("skips duplicate address and creates new one", () => {
const canonical = { id: 1, addresses: [makeAddress({ id: 9 })] };
const duplicate = {
id: 2,
orders: [],
addresses: [
makeAddress({ id: 10, address1: "123 Main St", postal_code: "90210", city: "Beverly Hills" }),
makeAddress({ id: 11, address1: "456 Oak Ave", postal_code: "10001", city: "New York" }),
],
};
const plan = planCustomerMerge(canonical, duplicate);
assert.deepEqual(plan.addressesToSkip, [10]);
assert.deepEqual(plan.addressesToCreate.map((a) => a.id), [11]);
});
test("address match is case insensitive", () => {
const canonical = { id: 1, addresses: [makeAddress({ id: 9, address1: "123 MAIN ST", city: "BEVERLY HILLS" })] };
const duplicate = { id: 2, orders: [], addresses: [makeAddress({ id: 10, address1: "123 main st", city: "beverly hills" })] };
const plan = planCustomerMerge(canonical, duplicate);
assert.deepEqual(plan.addressesToSkip, [10]);
assert.deepEqual(plan.addressesToCreate, []);
});
test("duplicateCustomerIdToDeactivate is the duplicate", () => {
const canonical = { id: 1, addresses: [] };
const duplicate = { id: 2, orders: [], addresses: [] };
const plan = planCustomerMerge(canonical, duplicate);
assert.equal(plan.duplicateCustomerIdToDeactivate, 2);
});
test("throws when duplicate id equals canonical id", () => {
const canonical = { id: 5, addresses: [] };
const duplicate = { id: 5, orders: [], addresses: [] };
assert.throws(() => planCustomerMerge(canonical, duplicate));
});
Case studies
The shopper who was two people in the admin panel
A repeat buyer checked out as a guest the first time, then registered a real account on a later visit using an email with different capitalization. Support saw two customer profiles, each with a thin order history, and had no way to combine them when the shopper asked about store credit from the earlier order.
Running the merge script clustered both records on the normalized email, picked the registered profile as canonical, and reassigned the guest order over to it. The shopper's full history showed up under one profile on the very next admin page load.
The support team that quietly doubled a VIP customer
A staff member took a phone order and typed the customer's email slightly wrong while creating a new profile by hand, not realizing the shopper already had an account. The VIP's order count in reporting looked lower than it actually was, split across two customer_id's.
The dry run output flagged the cluster immediately, since both records shared the same name and phone number even though the email differed by one character. After a quick manual confirmation of the email typo, the reassignment ran for real and the VIP's order history and address book were both correct under the original profile.
After running this on a schedule, orders and addresses from a duplicate BigCommerce customer record land back on the shopper's real profile without anyone hand-editing anything in the admin. The duplicate customer_id is never deleted automatically. It is only ever flagged, so a human always makes the final call before that record disappears for good.
FAQ
Why does BigCommerce not have a customer merge endpoint?
BigCommerce creates a separate customer_id for every guest checkout, storefront registration, and admin-panel entry, and the platform treats each one as a fully independent entity with its own owned orders (order.customer_id) and owned addresses (address.customer_id). There is no merge or alias relationship in the data model, and the REST Management API only exposes CRUD on individual resources, never a bulk reassign-all-child-resources operation. This is a recurring, unanswered request on BigCommerce's own community support forum.
Is it safe to just delete the duplicate customer record?
Not by itself. Every order still pointing at the duplicate customer_id (order.customer_id) and every address still owned by it (address.customer_id) has to be reassigned or recreated on the canonical customer first. Deleting a customer_id that still has an order pointing to it, for example because a reassignment call partially failed, corrupts reporting integrity, so the duplicate should only be deactivated or flagged for human confirmation, never auto-deleted.
How do I find which customer records are actually duplicates of the same person?
Pull customers with GET /v3/customers, normalize and lowercase the email addresses, and cluster customer_id values that share the same normalized email. Use first_name, last_name, and phone as a secondary signal, since a guest-checkout customer can later register under a slightly different email. Within each cluster, pick the canonical customer_id, for example the lowest id or the fully-registered profile over a guest-converted stub, and treat the rest as duplicates to repair.
Related field notes
Citations
On the problem:
- BigCommerce Support Community: how do you merge 2 same customers. support.bigcommerce.com how do you merge 2 same customers
- BigCommerce Support Community: merging customer accounts. support.bigcommerce.com merging customer accounts
- BigCommerce Support Community: can you merge customers if the same customer has two accounts. support.bigcommerce.com can you merge customers with two accounts
On the solution:
- BigCommerce Developer Center: the Customers V3 REST Management API. developer.bigcommerce.com customers
- BigCommerce Developer Center: the Orders V2 REST Management API. developer.bigcommerce.com orders
- BigCommerce Developer Center: the Customers V3 Addresses endpoint. developer.bigcommerce.com customer addresses
Stuck on a tricky one?
If you have a problem in BigCommerce customers, 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 customers?
If this saved you a pile of manual clicks or caught duplicates you would have otherwise missed, 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