Repair Customers

BigCommerce guest orders not linked to accounts

A shopper checks out as a guest using the same email address they already used to register, or the same email they register with next week. Anyone glancing at the order and the customer record would call these the same person. But BigCommerce never makes that connection itself. The guest order sits at customer_id 0 forever, disconnected from the account, unless someone opens the order in the admin and reassigns it by hand. At scale that means loyalty history, reorder, and lifetime value reports quietly miss every guest purchase whose email happens to match a real account. Here is why that gap never closes on its own and a small script that finds the confident matches and links them safely.

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

BigCommerce stores every guest checkout with customer_id set to 0, and it never retroactively links that order to a customer record, even when the same email registers an account later or already has one. The storefront and Order Management UI have no automatic "same email, different order" reconciliation, so the two stay disconnected until a human edits the order to "Existing customer." Run a small Python or Node.js script that lists guest orders with GET /v2/orders?customer_id=0, reads each order's billing email, resolves it against the customer table with GET /v3/customers?email:in=<email>, and reassigns customer_id with PUT /v2/orders/{order_id} only when exactly one confident match exists. Zero matches or multiple matches get flagged for manual review instead of a blind write. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce checkout lets shoppers buy without registering an account. That is good for conversion, but it means the order that comes out the other end has no owner beyond a name and an email typed into the billing form. BigCommerce marks that order's customer_id as 0, its literal marker for a guest checkout, and that value never changes on its own.

The trouble starts when the same person shows up twice. Maybe they registered an account months ago and forgot to log in this time. Maybe they check out as a guest today and create an account tomorrow with the identical email. Either way, a human looking at both records would say "that is obviously the same customer." BigCommerce does not make that same leap. The order and the account live as two disconnected entities, matched only in the merchant's head, unless someone manually reassigns the order in the admin.

Guest checks out same email as an account Order stored customer_id = 0 No automatic reconciliation Account unaware order never joins it Reports miss the sale
The order and the account belong to the same person, but nothing in BigCommerce carries that knowledge from the checkout into the customer record, so customer_id stays 0.

Why it happens

BigCommerce's guest checkout is deliberately simple: it stores a billing name and email on the order and nothing more. A few common ways stores end up with a pile of guest orders that really belong to a known customer:

This is a quiet but expensive gap. Loyalty history, reorder suggestions, and lifetime value reporting all read from the customer record, so every guest order with a matching email silently disappears from those numbers, understating how valuable a real, repeat customer actually is. See the citations at the end for the exact support threads and API docs.

The key insight

A matching email is not automatically proof of a safe link. So the safe pattern is not "link every guest order to whichever account shares its email." It is "link only the orders where exactly one customer record matches, and leave the rest for a human." We check GET /v3/customers?email:in=<email> for each guest order's billing email, and we only ever write customer_id when that lookup returns exactly one confident match, normalized for case and whitespace.

The fix, as a flow

We do not touch checkout or how guest orders are created. We add a job that lists guest orders, looks up the billing email against the customer table, and reassigns customer_id only for the orders where the match is unambiguous. Anything with zero matches or more than one match gets flagged instead of guessed at.

Scheduled job runs on a timer List guest orders customer_id = 0 Look up billing email against the customer table Exactly one confident match? yes no, flag for review customer_id set order joins the account
The script only reassigns customer_id when the billing email resolves to exactly one customer record. Zero or multiple matches are flagged, not guessed at.

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 Orders (modify) and Customers (read-only) scope so it can read and update orders and read the customer table. 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 LOOKBACK_DAYS="30"
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 LOOKBACK_DAYS="30"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V2 Orders and V3 Customers REST APIs

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/ with the token in the X-Auth-Token header. Orders live on V2, customers on V3, so a small helper handles both bases plus GET and PUT and raises on a non-2xx response. We reuse it to list guest orders, read billing emails, look up customers, and write the reassignment.

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(path, body):
    r = requests.put(f"{API_BASE_V2}{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(path, body) {
  const res = await fetch(`${API_BASE_V2}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List guest orders and resolve each billing email

Call GET /v2/orders?customer_id=0, paginated, to get orders BigCommerce marks as guest checkouts within your lookback window. For each one, read billing_address.email from GET /v2/orders/{id}, then resolve that email against the customer table with GET /v3/customers?email:in=<email>, which returns 0 or more matches with their id.

step3.py
def guest_orders(lookback_days):
    page = 1
    while True:
        orders = bc_get(API_BASE_V2, "/orders", {
            "customer_id": 0,
            "min_date_created": f"-{lookback_days} days",
            "page": page,
            "limit": 250,
        })
        if not orders:
            return
        for order in orders:
            yield order
        page += 1

def order_billing_email(order_id):
    order = bc_get(API_BASE_V2, f"/orders/{order_id}")
    return ((order or {}).get("billing_address") or {}).get("email", "")

def find_customer_matches(email):
    if not email:
        return []
    data = bc_get(API_BASE_V3, "/customers", {"email:in": email})
    return [{"id": c["id"], "email": c.get("email", "")} for c in (data or {}).get("data", [])]
step3.js
async function* guestOrders(lookbackDays) {
  let page = 1;
  while (true) {
    const orders = await bcGet(API_BASE_V2, "/orders", {
      customer_id: 0,
      min_date_created: `-${lookbackDays} days`,
      page,
      limit: 250,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function orderBillingEmail(orderId) {
  const order = await bcGet(API_BASE_V2, `/orders/${orderId}`);
  return (order && order.billing_address && order.billing_address.email) || "";
}

async function findCustomerMatches(email) {
  if (!email) return [];
  const data = await bcGet(API_BASE_V3, "/customers", { "email:in": email });
  return ((data && data.data) || []).map((c) => ({ id: c.id, email: c.email || "" }));
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order and its pre-fetched customer matches, and returns an action. The rule is strict on purpose. Skip anything already linked or that is Incomplete, Cancelled, or Declined. Flag anything with zero or more than one match. Link only when exactly one customer record matches, and its email, lowercased and trimmed, is strictly equal to the order's billing email, lowercased and trimmed.

decide.py
GUEST_CUSTOMER_ID = 0
EXCLUDED_STATUSES = {0, 5, 6}  # Incomplete, Cancelled, Declined

def decide_order_link(order, customer_matches):
    if order.get("customer_id") != GUEST_CUSTOMER_ID:
        return {"action": "skip", "targetCustomerId": None, "reason": "already linked to a customer"}
    if order.get("status_id") in EXCLUDED_STATUSES:
        return {"action": "skip", "targetCustomerId": None, "reason": "incomplete, cancelled, or declined"}
    if len(customer_matches) == 0:
        return {"action": "flag", "targetCustomerId": None, "reason": "no account matches this email"}
    if len(customer_matches) > 1:
        return {"action": "flag", "targetCustomerId": None, "reason": "multiple accounts share this email"}

    match = customer_matches[0]
    order_email = (order.get("billing_email") or "").strip().lower()
    match_email = (match.get("email") or "").strip().lower()
    if match_email != order_email:
        return {"action": "flag", "targetCustomerId": None, "reason": "email does not match exactly"}
    return {"action": "link", "targetCustomerId": match["id"], "reason": "exactly one confident email match"}
decide.js
const GUEST_CUSTOMER_ID = 0;
const EXCLUDED_STATUSES = new Set([0, 5, 6]); // Incomplete, Cancelled, Declined

export function decideOrderLink(order, customerMatches) {
  if (order.customer_id !== GUEST_CUSTOMER_ID) {
    return { action: "skip", targetCustomerId: null, reason: "already linked to a customer" };
  }
  if (EXCLUDED_STATUSES.has(order.status_id)) {
    return { action: "skip", targetCustomerId: null, reason: "incomplete, cancelled, or declined" };
  }
  if (customerMatches.length === 0) {
    return { action: "flag", targetCustomerId: null, reason: "no account matches this email" };
  }
  if (customerMatches.length > 1) {
    return { action: "flag", targetCustomerId: null, reason: "multiple accounts share this email" };
  }

  const match = customerMatches[0];
  const orderEmail = (order.billing_email || "").trim().toLowerCase();
  const matchEmail = (match.email || "").trim().toLowerCase();
  if (matchEmail !== orderEmail) {
    return { action: "flag", targetCustomerId: null, reason: "email does not match exactly" };
  }
  return { action: "link", targetCustomerId: match.id, reason: "exactly one confident email match" };
}
5

Link the order the same way a person would in the admin

When the decision says the order should link, call PUT /v2/orders/{order_id} with {"customer_id": matched_customer_id}. That is the same field the admin's "Existing customer" order-edit flow writes, and it never alters totals, line items, or status. Orders the decision flags are left with customer_id at 0, ready for a person to review and reassign by hand.

apply.py
def link_order(order_id, customer_id):
    return bc_put(f"/orders/{order_id}", {"customer_id": customer_id})
apply.js
async function linkOrder(orderId, customerId) {
  return bcPut(`/orders/${orderId}`, { customer_id: customerId });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs which orders it would link, with the order id, the matched customer id, and the matched email. Read the output, agree with it, then switch it off. Run it on a schedule that matches how often guest checkouts come in, for example once a day.

Run it safe

Always start with DRY_RUN=true, and never let the script link an order when the email match is ambiguous, such as multiple customers sharing that email after a merge, or an email that only looks similar. Those get flagged for a human to confirm in the admin's "Existing customer" order-edit flow instead of a guess.

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 with exactly one confident email match, and flags everything else for a person to decide.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

link_guest_orders.py
"""Link BigCommerce guest orders to the customer account that matches the billing email.

BigCommerce checkout lets shoppers buy without registering. Every guest order
is stored with customer_id = 0 permanently, and BigCommerce never retroactively
links it, even if that same email later registers or already has an account.
Matching is name and email based only in the merchant's head, since the
storefront and Order Management UI have no automatic "same email, different
order" reconciliation. At scale this means loyalty history, reorder, and
lifetime value reporting silently miss every guest purchase whose email
happens to match a real account.

This job lists guest orders (customer_id = 0) within a lookback window, reads
each order's billing email, resolves that email against the customer table,
and reassigns customer_id only when there is exactly one confident match.
Orders with zero matches (no account exists) or more than one match
(ambiguous, e.g. after a merge) are left untouched for manual review in the
admin's "Existing customer" order-edit flow. Run on a schedule. Safe to run
again and again.

Guide: https://www.allanninal.dev/bigcommerce/guest-orders-not-linked-to-accounts/
"""
import os
import logging

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("link_guest_orders")

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"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

GUEST_CUSTOMER_ID = 0
# Skip Incomplete (0), Cancelled (5), Declined (6). Everything else is a real order.
EXCLUDED_STATUSES = {0, 5, 6}

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()
    if not r.text:
        return []
    return r.json()


def bc_put(path, body):
    r = requests.put(f"{API_BASE_V2}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def decide_order_link(order, customer_matches):
    """Pure decision. No network, no DB calls.

    order: {id, customer_id, billing_email, status_id}
    customer_matches: [{id, email}, ...] pre-fetched matches for the order's email

    Returns {"action": "link" | "flag" | "skip", "targetCustomerId": int | None, "reason": str}
    """
    if order.get("customer_id") != GUEST_CUSTOMER_ID:
        return {"action": "skip", "targetCustomerId": None, "reason": "already linked to a customer"}

    if order.get("status_id") in EXCLUDED_STATUSES:
        return {"action": "skip", "targetCustomerId": None, "reason": "incomplete, cancelled, or declined"}

    if len(customer_matches) == 0:
        return {"action": "flag", "targetCustomerId": None, "reason": "no account matches this email"}

    if len(customer_matches) > 1:
        return {"action": "flag", "targetCustomerId": None, "reason": "multiple accounts share this email"}

    match = customer_matches[0]
    order_email = (order.get("billing_email") or "").strip().lower()
    match_email = (match.get("email") or "").strip().lower()
    if match_email != order_email:
        return {"action": "flag", "targetCustomerId": None, "reason": "email does not match exactly"}

    return {"action": "link", "targetCustomerId": match["id"], "reason": "exactly one confident email match"}


def guest_orders():
    """Page through orders whose customer_id is 0 (guest checkout) within the lookback window."""
    page = 1
    while True:
        orders = bc_get(
            API_BASE_V2,
            "/orders",
            {
                "customer_id": GUEST_CUSTOMER_ID,
                "min_date_created": f"-{LOOKBACK_DAYS} days",
                "page": page,
                "limit": 250,
            },
        )
        if not orders:
            return
        for order in orders:
            yield order
        page += 1


def order_billing_email(order_id):
    order = bc_get(API_BASE_V2, f"/orders/{order_id}")
    return ((order or {}).get("billing_address") or {}).get("email", "")


def find_customer_matches(email):
    if not email:
        return []
    data = bc_get(API_BASE_V3, "/customers", {"email:in": email})
    return [{"id": c["id"], "email": c.get("email", "")} for c in (data or {}).get("data", [])]


def link_order(order_id, customer_id):
    return bc_put(f"/orders/{order_id}", {"customer_id": customer_id})


def run():
    linked = 0
    flagged = 0
    for order in guest_orders():
        order_id = order["id"]
        billing_email = order_billing_email(order_id)
        matches = find_customer_matches(billing_email)

        decision = decide_order_link(
            {
                "id": order_id,
                "customer_id": order.get("customer_id", GUEST_CUSTOMER_ID),
                "billing_email": billing_email,
                "status_id": order.get("status_id"),
            },
            matches,
        )

        if decision["action"] == "skip":
            continue

        if decision["action"] == "flag":
            log.warning(
                "order_id=%s billing_email=%s matches=%d flagged: %s",
                order_id, billing_email, len(matches), decision["reason"],
            )
            flagged += 1
            continue

        log.info(
            "order_id=%s old_customer_id=0 new_customer_id=%s matched_email=%s %s",
            order_id, decision["targetCustomerId"], billing_email,
            "would link" if DRY_RUN else "linking",
        )
        if not DRY_RUN:
            link_order(order_id, decision["targetCustomerId"])
        linked += 1

    log.info(
        "Done. %d order(s) %s, %d order(s) flagged for manual review.",
        linked, "to link" if DRY_RUN else "linked", flagged,
    )


if __name__ == "__main__":
    run()
link-guest-orders.js
/**
 * Link BigCommerce guest orders to the customer account that matches the billing email.
 *
 * BigCommerce checkout lets shoppers buy without registering. Every guest
 * order is stored with customer_id = 0 permanently, and BigCommerce never
 * retroactively links it, even if that same email later registers or already
 * has an account. Matching is name and email based only in the merchant's
 * head, since the storefront and Order Management UI have no automatic
 * "same email, different order" reconciliation. At scale this means loyalty
 * history, reorder, and lifetime value reporting silently miss every guest
 * purchase whose email happens to match a real account.
 *
 * This job lists guest orders (customer_id = 0) within a lookback window,
 * reads each order's billing email, resolves that email against the customer
 * table, and reassigns customer_id only when there is exactly one confident
 * match. Orders with zero matches (no account exists) or more than one match
 * (ambiguous, e.g. after a merge) are left untouched for manual review in the
 * admin's "Existing customer" order-edit flow. Run on a schedule. Safe to run
 * again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/guest-orders-not-linked-to-accounts/
 */
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const GUEST_CUSTOMER_ID = 0;
// Skip Incomplete (0), Cancelled (5), Declined (6). Everything else is a real order.
const EXCLUDED_STATUSES = new Set([0, 5, 6]);

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

/**
 * Pure decision. No network, no DB calls.
 *
 * order: {id, customer_id, billing_email, status_id}
 * customerMatches: [{id, email}, ...] pre-fetched matches for the order's email
 *
 * Returns {action: "link" | "flag" | "skip", targetCustomerId: number | null, reason: string}
 */
export function decideOrderLink(order, customerMatches) {
  if (order.customer_id !== GUEST_CUSTOMER_ID) {
    return { action: "skip", targetCustomerId: null, reason: "already linked to a customer" };
  }

  if (EXCLUDED_STATUSES.has(order.status_id)) {
    return { action: "skip", targetCustomerId: null, reason: "incomplete, cancelled, or declined" };
  }

  if (customerMatches.length === 0) {
    return { action: "flag", targetCustomerId: null, reason: "no account matches this email" };
  }

  if (customerMatches.length > 1) {
    return { action: "flag", targetCustomerId: null, reason: "multiple accounts share this email" };
  }

  const match = customerMatches[0];
  const orderEmail = (order.billing_email || "").trim().toLowerCase();
  const matchEmail = (match.email || "").trim().toLowerCase();
  if (matchEmail !== orderEmail) {
    return { action: "flag", targetCustomerId: null, reason: "email does not match exactly" };
  }

  return { action: "link", targetCustomerId: match.id, reason: "exactly one confident email match" };
}

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(path, body) {
  const res = await fetch(`${API_BASE_V2}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function* guestOrders() {
  let page = 1;
  while (true) {
    const orders = await bcGet(API_BASE_V2, "/orders", {
      customer_id: GUEST_CUSTOMER_ID,
      min_date_created: `-${LOOKBACK_DAYS} days`,
      page,
      limit: 250,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function orderBillingEmail(orderId) {
  const order = await bcGet(API_BASE_V2, `/orders/${orderId}`);
  return (order && order.billing_address && order.billing_address.email) || "";
}

async function findCustomerMatches(email) {
  if (!email) return [];
  const data = await bcGet(API_BASE_V3, "/customers", { "email:in": email });
  return ((data && data.data) || []).map((c) => ({ id: c.id, email: c.email || "" }));
}

async function linkOrder(orderId, customerId) {
  return bcPut(`/orders/${orderId}`, { customer_id: customerId });
}

export async function run() {
  let linked = 0;
  let flagged = 0;

  for await (const order of guestOrders()) {
    const orderId = order.id;
    const billingEmail = await orderBillingEmail(orderId);
    const matches = await findCustomerMatches(billingEmail);

    const decision = decideOrderLink(
      {
        id: orderId,
        customer_id: order.customer_id ?? GUEST_CUSTOMER_ID,
        billing_email: billingEmail,
        status_id: order.status_id,
      },
      matches,
    );

    if (decision.action === "skip") continue;

    if (decision.action === "flag") {
      console.warn(
        `order_id=${orderId} billing_email=${billingEmail} matches=${matches.length} flagged: ${decision.reason}`,
      );
      flagged += 1;
      continue;
    }

    console.log(
      `order_id=${orderId} old_customer_id=0 new_customer_id=${decision.targetCustomerId} matched_email=${billingEmail} ` +
      `${DRY_RUN ? "would link" : "linking"}`,
    );
    if (!DRY_RUN) await linkOrder(orderId, decision.targetCustomerId);
    linked += 1;
  }

  console.log(
    `Done. ${linked} order(s) ${DRY_RUN ? "to link" : "linked"}, ${flagged} order(s) flagged for manual 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 whether a real order gets reassigned to a real customer account. Because decide_order_link takes only plain values and returns a plain object, the test needs no network and no BigCommerce store. It just feeds in fixture arrays and checks the answer.

test_guest_order_link.py
from link_guest_orders import decide_order_link


def order(**over):
    base = {"id": 101, "customer_id": 0, "billing_email": "jane@example.com", "status_id": 11}
    base.update(over)
    return base


def match(customer_id=555, email="jane@example.com"):
    return {"id": customer_id, "email": email}


def test_link_when_exactly_one_confident_match():
    decision = decide_order_link(order(), [match()])
    assert decision["action"] == "link"
    assert decision["targetCustomerId"] == 555


def test_link_is_case_and_whitespace_insensitive():
    decision = decide_order_link(
        order(billing_email="  Jane@Example.com  "),
        [match(email="jane@example.com")],
    )
    assert decision["action"] == "link"
    assert decision["targetCustomerId"] == 555


def test_skip_when_already_linked_to_a_customer():
    decision = decide_order_link(order(customer_id=42), [match()])
    assert decision["action"] == "skip"
    assert decision["targetCustomerId"] is None


def test_skip_when_status_incomplete():
    decision = decide_order_link(order(status_id=0), [match()])
    assert decision["action"] == "skip"


def test_skip_when_status_cancelled():
    decision = decide_order_link(order(status_id=5), [match()])
    assert decision["action"] == "skip"


def test_skip_when_status_declined():
    decision = decide_order_link(order(status_id=6), [match()])
    assert decision["action"] == "skip"


def test_flag_when_no_matches():
    decision = decide_order_link(order(), [])
    assert decision["action"] == "flag"
    assert decision["targetCustomerId"] is None


def test_flag_when_multiple_matches():
    decision = decide_order_link(order(), [match(1, "jane@example.com"), match(2, "jane@example.com")])
    assert decision["action"] == "flag"
    assert decision["targetCustomerId"] is None


def test_flag_when_email_does_not_match_exactly():
    decision = decide_order_link(order(billing_email="jane@example.com"), [match(email="j.ane@example.com")])
    assert decision["action"] == "flag"
    assert decision["targetCustomerId"] is None
link-guest-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOrderLink } from "./link-guest-orders.js";

const order = (over = {}) => ({
  id: 101, customer_id: 0, billing_email: "jane@example.com", status_id: 11, ...over,
});

const match = (id = 555, email = "jane@example.com") => ({ id, email });

test("link when exactly one confident match", () => {
  const decision = decideOrderLink(order(), [match()]);
  assert.equal(decision.action, "link");
  assert.equal(decision.targetCustomerId, 555);
});

test("link is case and whitespace insensitive", () => {
  const decision = decideOrderLink(
    order({ billing_email: "  Jane@Example.com  " }),
    [match(555, "jane@example.com")],
  );
  assert.equal(decision.action, "link");
  assert.equal(decision.targetCustomerId, 555);
});

test("skip when already linked to a customer", () => {
  const decision = decideOrderLink(order({ customer_id: 42 }), [match()]);
  assert.equal(decision.action, "skip");
  assert.equal(decision.targetCustomerId, null);
});

test("skip when status incomplete", () => {
  const decision = decideOrderLink(order({ status_id: 0 }), [match()]);
  assert.equal(decision.action, "skip");
});

test("skip when status cancelled", () => {
  const decision = decideOrderLink(order({ status_id: 5 }), [match()]);
  assert.equal(decision.action, "skip");
});

test("skip when status declined", () => {
  const decision = decideOrderLink(order({ status_id: 6 }), [match()]);
  assert.equal(decision.action, "skip");
});

test("flag when no matches", () => {
  const decision = decideOrderLink(order(), []);
  assert.equal(decision.action, "flag");
  assert.equal(decision.targetCustomerId, null);
});

test("flag when multiple matches", () => {
  const decision = decideOrderLink(order(), [match(1, "jane@example.com"), match(2, "jane@example.com")]);
  assert.equal(decision.action, "flag");
  assert.equal(decision.targetCustomerId, null);
});

test("flag when email does not match exactly", () => {
  const decision = decideOrderLink(order({ billing_email: "jane@example.com" }), [match(555, "j.ane@example.com")]);
  assert.equal(decision.action, "flag");
  assert.equal(decision.targetCustomerId, null);
});

Case studies

Forgot to log in

The regular who kept checking out as a guest

A subscription box store had a loyal customer who reordered every couple of months but rarely bothered logging in, since the storefront let her check out as a guest just as fast. Half a dozen orders piled up at customer_id 0, all with the same email as her real account, and her lifetime value report showed less than half of what she had actually spent.

Now a nightly job resolves every guest order's billing email against the customer table. Her missing orders linked automatically the first time it ran, since her email matched exactly one account, and every new guest order that matches links the same day.

Post-registration cleanup

The store that only noticed after a loyalty program launch

A home goods store rolled out a points-based loyalty program and expected it to calculate correctly off existing order history. Instead, customers complained their points looked wrong, and the store realized months of guest orders with matching emails had never joined the accounts that later registered.

Running the script with a generous lookback window found the backlog in one pass. Orders with a single confident match were linked, and the handful sharing an email across two accounts, from an earlier customer merge, were flagged and resolved by hand in a single afternoon.

What good looks like

After this runs on a schedule, a guest order with a matching email is never more than one run away from joining the right customer account, and loyalty history, reorder suggestions, and lifetime value reports finally reflect what a customer actually spent. Orders where the match is not obvious stay untouched at customer_id 0, waiting for a person to confirm them in the admin's "Existing customer" flow, so nothing gets linked on a guess.

FAQ

Why does not BigCommerce link a guest order to a matching customer account on its own?

A guest checkout is stored with customer_id 0 permanently. BigCommerce never goes back and checks whether that email later registered or already has an account, because the storefront and Order Management UI have no automatic same email, different order reconciliation. The two records just live side by side until a person opens the order and reassigns it by hand.

How do I find guest orders that could be linked to an account?

Call GET /v2/orders?customer_id=0 to list orders BigCommerce marks as guest checkouts, then read each order's billing_address.email from GET /v2/orders/{id}. Resolve that email against the customer table with GET /v3/customers?email:in=<email>. Exactly one match is safe to link, zero matches means no account exists yet, and more than one match is ambiguous and needs a human to look at it.

Is it safe to automatically reassign a guest order to a customer account?

Only when exactly one customer record matches the order's billing email, normalized for case and whitespace. Multiple accounts sharing an email, or an email that only looks similar, should be flagged for manual review in the admin's Existing customer order-edit flow rather than linked automatically. Reassigning customer_id never changes totals, line items, or status, so a confident link is low risk, but an ambiguous one is not worth guessing at.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: Can I add guest orders to customer account? support.bigcommerce.com can-i-add-guest-orders-to-customer-account
  2. BigCommerce Support: How do you add an existing order that was ordered as guest to a member account? support.bigcommerce.com how-do-you-add-an-existing-order
  3. BigCommerce Help Center: Guest Customers and Checkout. support.bigcommerce.com Guest-Customers-and-Checkout

On the solution:

  1. BigCommerce Developer Center: Orders, REST Management API reference. developer.bigcommerce.com/docs/rest-management/orders
  2. BigCommerce Docs: Update Order, PUT /v2/orders/{order_id} and the customer_id field. docs.bigcommerce.com update-order
  3. BigCommerce Developer Center: Customers V3, GET /v3/customers and the email:in filter. developer.bigcommerce.com/docs/rest-management/customers

Stuck on a tricky one?

If you have a problem in BigCommerce orders, customers, 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 reunite some missing order history?

If this found a pile of guest orders that belonged to a real customer, or saved you an afternoon of manual reassignment in the admin, 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