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.

Python and Node.js V2 Orders and V3 Customers API Safe by default (dry run)
The short answer

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.

Guest checkout customer_id = 0 Self-registration storefront account Admin creation control panel V3 API import POS or ERP upsert Checked in isolation unique only per path no reconciliation Split identity same email, 2+ ids Order history fragmented
Each creation path checks uniqueness only against its own record type. Nothing looks across paths, so the same person can pile up as several customer records, or a guest order that never becomes a customer at all.

Why it happens

BigCommerce's email uniqueness rule is scoped, not global. A few common ways stores end up with duplicates anyway:

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.

The key insight

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.

Scheduled job runs on a timer Pull customers and guest orders customer_id=0 Group and pick normalize, earliest wins Reassign confirmed? yes no, hold Delete loser DELETE /v3/customers manual approval
The script only deletes a duplicate customer after re-checking that the loser's orders are truly gone. In dry run it only prints the merge plan for a human to review.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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
step2.js
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;
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

plan.py
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
plan.js
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;
}
5

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.

apply.py
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}")
apply.js
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}`);
}
6

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.

Run it safe

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.

merge_duplicate_customers.py
"""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()
merge-duplicate-customers.js
/**
 * 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.

test_duplicate_merge_plan.py
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, []) == []
merge-duplicate-customers.test.js
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

Guest checkout

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.

POS import

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.

What good looks like

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:

  1. BigCommerce Support: duplicate customers are getting created in BigCommerce with the same email id. support.bigcommerce.com duplicate customers same email id
  2. BigCommerce Support: multiple customer accounts, with same email. support.bigcommerce.com multiple customer accounts with same email
  3. BigCommerce Support: linking a guest order to a customer's profile. support.bigcommerce.com linking guest order to customers profile

On the solution:

  1. BigCommerce Developer Center: Customers V3, the REST Management API reference. developer.bigcommerce.com rest-management customers
  2. BigCommerce Developer Center: Customers V2, the REST Management API reference. developer.bigcommerce.com rest-management customers-v2
  3. 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.

Contact me on LinkedIn

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

Back to all BigCommerce field notes