Repair WooCommerce core: customers and linking

Guest orders not linked to accounts

A shopper checks out as a guest using the exact email address they already have an account under, and the order never shows up in "My account". Support gets a "where is my order history" ticket, loyalty points never accrue, and lifetime value reports quietly undercount that customer. Here is why WooCommerce leaves the order unlinked and a small script that relinks it by email, safely.

Python and Node.js Runs on a schedule Safe by default (dry run)
A group of people posing for a photo
Photo by Julee Juu on Unsplash
The short answer

WooCommerce only sets customer_id on an order when the shopper is logged in at checkout. A guest checkout always saves customer_id as 0, even when the billing email is identical to a real account. Run a small Python or Node.js script on a schedule that finds guest orders, looks up the billing email with the WooCommerce REST API, and links the order to that account only when the match is exact and the payment is confirmed. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce decides whether an order belongs to an account at the moment checkout happens, not afterward. If the shopper is signed in, the order gets that account's customer_id. If they check out as a guest, the order gets customer_id 0, full stop, no matter what email they typed in.

The problem is that a lot of guests already have an account. They forgot they signed up, they used a different device, or they just did not want to stop and log in. Their billing email matches their account email exactly, but WooCommerce never checks that. The order and the account exist side by side in the same database and never meet.

Shopper has an account already Checks out as a guest email never checked Order saved customer_id: 0 No history No points
The billing email matches the account exactly. WooCommerce never compares them, so the order is orphaned from the account it belongs to.

Why it happens

This is by design, not a bug in the checkout flow itself, but it leaves a gap that stores feel every day. A few things make it worse:

The WooCommerce REST API exposes exactly the two lookups needed to fix this without touching the database directly: orders filtered by customer=0, and customers filtered by email. See the citations at the end for the exact endpoints.

The key insight

The billing email on the order is the one fact that reliably ties a guest order to an account, when exactly one account uses that email. If more than one account shares it, guessing is worse than doing nothing, so the safe move is to skip and flag it instead of picking one.

The fix, as a flow

We do not touch checkout. We add a job that runs on a schedule, finds recent orders still sitting at customer_id 0, and looks up the billing email against the customers list. When exactly one account matches and the order's payment is confirmed against Stripe, we set customer_id on the order and leave a note. Everything else is skipped and logged for a human to look at.

Scheduled job daily or weekly List orders where customer=0 Look up customers by billing email One match and payment ok? yes no, skip and log Link account set customer_id + note
The script only links an order when the email points to exactly one account and the payment checks out. Anything uncertain is left alone.

Build it step by step

1

Get access to both systems

You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders and customers, plus a Stripe secret key so the script can double check a payment before it links an order to someone's account. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export REQUIRE_PAID="true"   # confirm a matching succeeded Stripe charge first
export DRY_RUN="true"        # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export REQUIRE_PAID="true"   // confirm a matching succeeded Stripe charge first
export DRY_RUN="true"        // start safe, change to false to write
2

List the guest orders from the lookback window

Ask the WooCommerce REST API for orders where customer=0, created after your lookback date. We page through every result so a large backlog does not get truncated.

step2.py
import os, datetime, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))

def guest_orders():
    page = 1
    after = f"{datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"customer": 0, "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* guestOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?customer=0&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}
3

Look up the billing email against customers

The WooCommerce REST API can filter customers by exact email. If nothing comes back, there is no account to link. If more than one account comes back, do not guess, since that usually means duplicate accounts already exist for that email.

step3.py
def find_customers_by_email(email):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/customers",
        params={"email": email, "per_page": 10},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()
step3.js
async function findCustomersByEmail(email) {
  return woo(`/customers?email=${encodeURIComponent(email)}&per_page=10`);
}
4

Read the saved Stripe PaymentIntent for a second opinion

Before linking someone's order to an account, it is worth confirming the order was actually paid, and that the Stripe charge amount matches the order total. The PaymentIntent id can live in order meta as _stripe_intent_id, or as the transaction_id field when it starts with pi_.

step4.py
import stripe

def intent_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step4.js
export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
5

Decide, with one pure function

Keep the decision in its own function that takes the order, the matching customers, and the Stripe intent, and returns an action. This is the part worth testing, so it stays pure with no network calls inside it. Already linked orders are skipped. Zero or many matching accounts are logged, not guessed. An unconfirmed payment is left alone. Only a single clean match with a confirmed charge gets linked.

decide.py
PAID_STATUSES = {"processing", "completed"}
REQUIRE_PAID = True  # set from os.environ in the full script

def order_amount_minor(order):
    return round(float(order["total"]) * 100)

def decide(order, customers, intent=None):
    if order.get("customer_id", 0):
        return ("skip", "order is already linked to an account")
    email = (order.get("billing") or {}).get("email")
    if not email:
        return ("skip", "no billing email on the order")
    if REQUIRE_PAID and order["status"] not in PAID_STATUSES:
        return ("skip", "order is not paid yet")
    if not customers:
        return ("no_account", "no registered account uses this email")
    if len(customers) > 1:
        return ("ambiguous", "more than one account uses this email")
    if REQUIRE_PAID:
        if intent is None:
            return ("unverified", "no Stripe PaymentIntent saved on the order")
        if intent.get("status") != "succeeded":
            return ("unverified", "Stripe does not show this payment as succeeded")
        if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
            return ("unverified", "order total does not match the Stripe charge")
    return ("link", f"billing email matches account {customers[0]['id']}")
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);
const REQUIRE_PAID = true; // read from process.env in the full script

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, customers, intent = null) {
  if (order.customer_id) return ["skip", "order is already linked to an account"];
  const email = (order.billing || {}).email;
  if (!email) return ["skip", "no billing email on the order"];
  if (REQUIRE_PAID && !PAID_STATUSES.has(order.status)) return ["skip", "order is not paid yet"];
  if (!customers || customers.length === 0) return ["no_account", "no registered account uses this email"];
  if (customers.length > 1) return ["ambiguous", "more than one account uses this email"];
  if (REQUIRE_PAID) {
    if (!intent) return ["unverified", "no Stripe PaymentIntent saved on the order"];
    if (intent.status !== "succeeded") return ["unverified", "Stripe does not show this payment as succeeded"];
    if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
      return ["unverified", "order total does not match the Stripe charge"];
    }
  }
  return ["link", `billing email matches account ${customers[0].id}`];
}
6

Link the order and wire it together with a dry run guard

When the action is link, set customer_id on the order and add a note so the shop manager can see why it changed. The loop ties every piece together, and the dry run guard means the first few runs only report what would happen. Read the output, trust it, then switch it off. Run it on a schedule with cron, daily or weekly is plenty since this problem does not need minute level freshness.

Run it safe

Always start with DRY_RUN=true. Linking an order changes who owns it in every report and every "My account" page, so you want to see the exact list before it writes anything.

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 only links an order once, so it is safe to run again and again.

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

link_guest_orders.py
"""Link guest WooCommerce orders to the account that shares the same email.

A guest checkout never sets order.customer_id, even when the billing email
matches a real, registered customer. The order sits at customer_id 0 forever,
so it never shows up in "My account", loyalty points never accrue, and any
per-customer report undercounts that shopper. This walks recent guest orders,
looks up a customer by billing email through the WooCommerce REST API, and
confirms the order was really paid by checking the saved Stripe PaymentIntent
before relinking it. Safe to run again and again. Dry run by default.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

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

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
REQUIRE_PAID = os.environ.get("REQUIRE_PAID", "true").lower() == "true"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def order_amount_minor(order):
    return round(float(order["total"]) * 100)


def decide(order, customers, intent=None):
    """Pure decision. customers is the list returned for the billing email lookup."""
    if order.get("customer_id", 0):
        return ("skip", "order is already linked to an account")
    email = (order.get("billing") or {}).get("email")
    if not email:
        return ("skip", "no billing email on the order")
    if REQUIRE_PAID and order["status"] not in PAID_STATUSES:
        return ("skip", "order is not paid yet")
    if not customers:
        return ("no_account", "no registered account uses this email")
    if len(customers) > 1:
        return ("ambiguous", "more than one account uses this email")
    if REQUIRE_PAID:
        if intent is None:
            return ("unverified", "no Stripe PaymentIntent saved on the order")
        if intent.get("status") != "succeeded":
            return ("unverified", "Stripe does not show this payment as succeeded")
        if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
            return ("unverified", "order total does not match the Stripe charge")
    return ("link", f"billing email matches account {customers[0]['id']}")


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def find_customers_by_email(email):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/customers",
        params={"email": email, "per_page": 10},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()


def guest_orders():
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"customer": 0, "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def link_order(order_id, customer_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
        json={"customer_id": customer_id},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": f"Linked this guest order to account {customer_id} because the "
                      f"billing email matched a registered customer. Linked by the reconciler."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    linked = 0
    for order in guest_orders():
        email = (order.get("billing") or {}).get("email")
        customers = find_customers_by_email(email) if email else []
        intent = get_intent(intent_id_of(order)) if REQUIRE_PAID else None
        action, reason = decide(order, customers, intent)
        if action == "link":
            customer_id = customers[0]["id"]
            log.info("Order %s: %s. %s", order["id"], reason, "would link" if DRY_RUN else "linking")
            if not DRY_RUN:
                link_order(order["id"], customer_id)
            linked += 1
        elif action in ("ambiguous", "unverified"):
            log.warning("Order %s not linked: %s", order["id"], reason)
    log.info("Done. %d order(s) %s.", linked, "to link" if DRY_RUN else "linked")


if __name__ == "__main__":
    run()
link-guest-orders.js
/**
 * Link guest WooCommerce orders to the account that shares the same email.
 *
 * A guest checkout never sets order.customer_id, even when the billing email
 * matches a real, registered customer. The order sits at customer_id 0 forever,
 * so it never shows up in "My account", loyalty points never accrue, and any
 * per-customer report undercounts that shopper. This walks recent guest orders,
 * looks up a customer by billing email through the WooCommerce REST API, and
 * confirms the order was really paid by checking the saved Stripe PaymentIntent
 * before relinking it. Safe to run again and again. Dry run by default.
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
const REQUIRE_PAID = (process.env.REQUIRE_PAID || "true").toLowerCase() === "true";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

/** Pure decision. customers is the list returned for the billing email lookup. */
export function decide(order, customers, intent = null) {
  if (order.customer_id) return ["skip", "order is already linked to an account"];
  const email = (order.billing || {}).email;
  if (!email) return ["skip", "no billing email on the order"];
  if (REQUIRE_PAID && !PAID_STATUSES.has(order.status)) return ["skip", "order is not paid yet"];
  if (!customers || customers.length === 0) return ["no_account", "no registered account uses this email"];
  if (customers.length > 1) return ["ambiguous", "more than one account uses this email"];
  if (REQUIRE_PAID) {
    if (!intent) return ["unverified", "no Stripe PaymentIntent saved on the order"];
    if (intent.status !== "succeeded") return ["unverified", "Stripe does not show this payment as succeeded"];
    if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
      return ["unverified", "order total does not match the Stripe charge"];
    }
  }
  return ["link", `billing email matches account ${customers[0].id}`];
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function findCustomersByEmail(email) {
  return woo(`/customers?email=${encodeURIComponent(email)}&per_page=10`);
}

async function* guestOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?customer=0&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function linkOrder(orderId, customerId) {
  await woo(`/orders/${orderId}`, {
    method: "PUT",
    body: JSON.stringify({ customer_id: customerId }),
  });
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Linked this guest order to account ${customerId} because the billing email ` +
            `matched a registered customer. Linked by the reconciler.`,
    }),
  });
}

export async function run() {
  let linked = 0;
  for await (const order of guestOrders()) {
    const email = (order.billing || {}).email;
    const customers = email ? await findCustomersByEmail(email) : [];
    const intent = REQUIRE_PAID ? await getIntent(intentIdOf(order)) : null;
    const [action, reason] = decide(order, customers, intent);
    if (action === "link") {
      const customerId = customers[0].id;
      console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would link" : "linking"}`);
      if (!DRY_RUN) await linkOrder(order.id, customerId);
      linked++;
    } else if (action === "ambiguous" || action === "unverified") {
      console.warn(`Order ${order.id} not linked: ${reason}`);
    }
  }
  console.log(`Done. ${linked} order(s) ${DRY_RUN ? "to link" : "linked"}.`);
}

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 shopper account gets an order attached to it. Because decide is pure, the test needs no network and no live store. It just feeds in plain objects and checks the action.

test_guest_link_decide.py
from link_guest_orders import decide, intent_id_of


def intent(**over):
    base = {"status": "succeeded", "amount_received": 5000}
    base.update(over)
    return base


def customer(id_=42):
    return [{"id": id_, "email": "shopper@example.com"}]


def test_link_when_one_account_matches_and_paid():
    order = {"customer_id": 0, "status": "processing", "total": "50.00",
             "billing": {"email": "shopper@example.com"}}
    assert decide(order, customer(), intent())[0] == "link"


def test_skip_when_already_linked():
    order = {"customer_id": 42, "status": "processing", "total": "50.00",
             "billing": {"email": "shopper@example.com"}}
    assert decide(order, customer(), intent())[0] == "skip"


def test_ambiguous_when_multiple_accounts_share_email():
    order = {"customer_id": 0, "status": "processing", "total": "50.00",
             "billing": {"email": "shopper@example.com"}}
    two = customer(1) + customer(2)
    assert decide(order, two, intent())[0] == "ambiguous"


def test_unverified_when_amount_mismatch():
    order = {"customer_id": 0, "status": "processing", "total": "80.00",
             "billing": {"email": "shopper@example.com"}}
    assert decide(order, customer(), intent())[0] == "unverified"


def test_intent_id_falls_back_to_transaction_id():
    order = {"meta_data": [], "transaction_id": "pi_456"}
    assert intent_id_of(order) == "pi_456"
link-guest-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./link-guest-orders.js";

const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });
const customer = (id = 42) => [{ id, email: "shopper@example.com" }];

test("link when one account matches and paid", () => {
  const order = { customer_id: 0, status: "processing", total: "50.00", billing: { email: "shopper@example.com" } };
  assert.equal(decide(order, customer(), intent())[0], "link");
});

test("skip when already linked", () => {
  const order = { customer_id: 42, status: "processing", total: "50.00", billing: { email: "shopper@example.com" } };
  assert.equal(decide(order, customer(), intent())[0], "skip");
});

test("ambiguous when multiple accounts share email", () => {
  const order = { customer_id: 0, status: "processing", total: "50.00", billing: { email: "shopper@example.com" } };
  const two = [...customer(1), ...customer(2)];
  assert.equal(decide(order, two, intent())[0], "ambiguous");
});

test("unverified when amount mismatch", () => {
  const order = { customer_id: 0, status: "processing", total: "80.00", billing: { email: "shopper@example.com" } };
  assert.equal(decide(order, customer(), intent())[0], "unverified");
});

test("intentIdOf falls back to transaction_id", () => {
  assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});

Case studies

Subscription box

The repeat buyer who looked like a stranger every time

A subscription box store noticed one shopper had five separate orders, but their loyalty dashboard showed zero history. Every order was placed as a guest with the same email as their dormant account from a signup two years earlier.

Running the script in dry run listed all five orders as clean single matches with confirmed Stripe charges. The team ran it for real, and the customer's lifetime value jumped from 0 to the true total overnight.

Duplicate accounts

The email that pointed to two accounts

A store had a period where a plugin bug created a second account for some emails. When the script hit those orders, it correctly reported them as ambiguous instead of picking one at random.

That list became the starting point for cleaning up duplicate customer accounts first. Once the duplicates were merged, a second run of this script linked the remaining orders cleanly.

What good looks like

After this runs on a schedule, a guest checkout is no longer a dead end for returning shoppers. Their order history, loyalty points, and lifetime value all catch up to reality within a day. Keep it running even after a backlog is cleared, since new guest orders with matching emails will keep showing up.

FAQ

Why does my WooCommerce guest order not show up under the customer's account?

WooCommerce only sets customer_id when the shopper is logged in at checkout. A guest checkout always saves customer_id as 0, even if the billing email matches a real account exactly, so the order never appears under that account.

Is it safe to relink an order to a customer automatically?

Yes, when the script only links an order when exactly one account uses the billing email and, unless you turn that check off, Stripe confirms a matching succeeded charge for the right amount. Orders with more than one match or no confirmed payment are left alone and logged for review.

What if two accounts share the same billing email?

The script skips the order and logs it as ambiguous rather than guessing. That case usually means duplicate customer accounts, which is its own problem worth fixing first.

Related field notes

Citations

On the problem:

  1. WooCommerce REST API docs: orders have a customer_id field and can be filtered by customer. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce docs: guest checkout and how account creation at checkout works. woocommerce.com/document/managing-orders
  3. WordPress developer reference: wc_create_new_customer and how orders relate to user accounts. developer.woocommerce.com/docs/order-data-storage-fundamentals

On the solution:

  1. WooCommerce REST API: list and filter customers, including by email. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: update an order and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
  3. Stripe API: retrieve a PaymentIntent to confirm status and amount received. docs.stripe.com/api/payment_intents/retrieve

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway 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 fix your unlinked orders?

If this saved you a pile of support tickets or cleaned up your customer reports, 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 WooCommerce and Stripe field notes