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.

Python and Node.js BigCommerce V2 Orders and V3 Customers API Safe by default (dry run)
A blue car being loaded onto a flatbed truck
Photo by fr0ggy5 on Unsplash
The short answer

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.

Same shopper Guest checkout Storefront registration Admin-panel entry 3 customer_id's no merge relation order.customer_id fragmented orders address.customer_id split addresses store credit split, unreachable No merge endpoint in the API
Each customer_id owns its own orders and addresses. Nothing in the data model, and no REST call, ever tells BigCommerce these records are the same person.

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:

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.

The key insight

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.

Cluster customers normalized email Pick canonical id lowest id or registered Reassign orders PUT /v2/orders/{id} customer_id: canonical Migrate addresses POST /v3/customers /addresses Flag duplicate id for human confirmation
The script reassigns orders and migrates addresses onto one canonical customer_id, then flags the duplicate for a human to confirm. It never calls delete on its own.

Build it step by step

1

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.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_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="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false to write
2

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.

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

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.

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

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.

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

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.

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

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.

Run it safe

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.

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

test_customer_merge_plan.py
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)
merge-duplicate-customers.test.js
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

Guest checkout, then registered

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.

Phone order entered by staff

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.

What good looks like

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:

  1. BigCommerce Support Community: how do you merge 2 same customers. support.bigcommerce.com how do you merge 2 same customers
  2. BigCommerce Support Community: merging customer accounts. support.bigcommerce.com merging customer accounts
  3. 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:

  1. BigCommerce Developer Center: the Customers V3 REST Management API. developer.bigcommerce.com customers
  2. BigCommerce Developer Center: the Orders V2 REST Management API. developer.bigcommerce.com orders
  3. 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.

Contact me on LinkedIn

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

Back to all BigCommerce field notes