Repair Account and store migration
Remap WooCommerce customers to the right orders after a store move
The migration finished, the new site looks fine, and then the tickets start. "I don't see my past orders." "My subscription shows on someone else's account." A store move rewrites WordPress user IDs, but every order on the old site was saved with the old numeric customer_id. Stripe still knows exactly who paid. Here is why the link breaks and a small script that remaps every order to the correct customer using the trail Stripe left behind.
A store move usually re-creates WordPress user accounts with new IDs, but every order still carries the old customer_id. The order is now silently attached to the wrong account, or to no account at all. Stripe's customer id, saved in order meta as _stripe_customer_id (or readable from the PaymentIntent), still identifies the real buyer. Run a small Python or Node.js script that reads that id plus the billing email on each order, finds the one WordPress user both signals agree on, and updates the order's customer_id to match. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce orders do not store "this order belongs to Jane." They store a number, the WordPress user ID of the customer, called customer_id. That number only means something in the context of one WordPress users table.
When a store moves, whether that is a host migration, a rebuild on a new WordPress install, or a merge of two shops, the users table is rarely carried over as-is. New accounts get created, some existing accounts get renumbered, and the mapping from "old user 482" to "new user 219" is not written down anywhere. The orders, meanwhile, were exported and imported with their original customer_id still attached. After the move, order 482 might now point at a stranger, or at a user ID that does not exist at all, which WooCommerce quietly treats as a guest order.
Why it happens
WooCommerce's own import and export tools are explicit that user IDs are not portable between installs, since WordPress assigns them in sequence and two sites will not agree on the same numbers. A few common ways this bites a store after a move:
- Customer accounts were imported through a generic WordPress user importer that assigns fresh IDs, while the order importer kept the original
customer_idcolumn untouched. - Two stores were merged into one, so a customer_id that used to mean one person on Store A now collides with a different person already using that same ID on Store B.
- The move used a database table prefix change or a partial restore that dropped the wp_users table but kept wp_wc_orders (or the legacy postmeta rows on HPOS-disabled sites), so the numbers are simply stale.
- Guest checkouts on the old site were later linked to an account by a "guest order matched to a customer" step that never ran again after the move, leaving those orders as customer_id 0.
Stripe is untouched by all of this. The Stripe customer id that was created at checkout time, and normally saved on the order as meta key _stripe_customer_id, keeps pointing at the same Stripe Customer object it always did. That is the one piece of the puzzle the migration could not scramble.
A numeric customer_id means nothing on its own after a migration. What still means something is the pair of the Stripe customer id and the billing email on the order. When both point at exactly one WordPress user, that user is almost certainly the right owner. When they disagree, or point at more than one user, that is a case for a person to review, not for a script to guess.
The fix, as a flow
We do not touch checkout or the migration tooling. We add a one-time (or on-demand) script that walks every order, reads its Stripe customer id and billing email, looks up which WordPress user on the new site owns that email, and only remaps the order when the Stripe id and the email agree on a single candidate. Anything ambiguous gets reported and left alone.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders and customers. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
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 DRY_RUN="true" # start safe, change to false to write
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 DRY_RUN="true" // start safe, change to false to write
List orders whose customer looks suspect
We only need to look closely at orders that are guest orders (customer_id of 0) or whose customer_id no longer resolves to a real WordPress user on the new site. Page through orders with the REST API and check each customer_id against the customers endpoint.
import os, 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"])
def all_orders():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"per_page": 50, "page": page, "orderby": "id", "order": "asc"},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
yield order
page += 1
def user_exists(customer_id):
if not customer_id:
return False
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}", auth=AUTH, timeout=30)
return r.status_code == 200
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");
async function woo(path) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
headers: { "Content-Type": "application/json", Authorization: AUTH },
});
return res;
}
async function* allOrders() {
let page = 1;
while (true) {
const res = await woo(`/orders?per_page=50&page=${page}&orderby=id&order=asc`);
const batch = await res.json();
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function userExists(customerId) {
if (!customerId) return false;
const res = await woo(`/customers/${customerId}`);
return res.status === 200;
}
Read the Stripe customer id off the order
WooCommerce's Stripe gateway saves the Stripe customer id as order meta, usually under _stripe_customer_id. If that meta is missing, fall back to reading it off the PaymentIntent named in _stripe_intent_id or the order's transaction_id, since the PaymentIntent also carries the customer reference.
import stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def meta_value(order, key):
for meta in order.get("meta_data") or []:
if meta.get("key") == key and meta.get("value"):
return meta["value"]
return None
def stripe_customer_id_of(order):
direct = meta_value(order, "_stripe_customer_id")
if direct:
return direct
intent_id = meta_value(order, "_stripe_intent_id") or order.get("transaction_id")
if intent_id and intent_id.startswith("pi_"):
intent = stripe.PaymentIntent.retrieve(intent_id)
return intent.get("customer")
return None
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
function metaValue(order, key) {
for (const meta of order.meta_data || []) {
if (meta.key === key && meta.value) return meta.value;
}
return null;
}
async function stripeCustomerIdOf(order) {
const direct = metaValue(order, "_stripe_customer_id");
if (direct) return direct;
const intentId = metaValue(order, "_stripe_intent_id") || order.transaction_id;
if (intentId && intentId.startsWith("pi_")) {
const intent = await stripe.paymentIntents.retrieve(intentId);
return intent.customer || null;
}
return null;
}
Decide, with one pure function
Keep the decision in its own function that takes the order, the Stripe customer id, and the list of WordPress users whose email matches the order's billing email, then returns an action. The rule is simple. If the order's current customer_id already resolves to a real user, leave it. If no email match exists, or more than one does, report it as ambiguous instead of guessing. Only remap when exactly one user matches the billing email and that user's id differs from what the order currently has.
def decide(order, current_customer_valid, matching_users):
# matching_users: list of WooCommerce customer dicts whose email equals
# the order's billing email, each with at least "id" and "stripe_customer_id".
if current_customer_valid:
return ("skip", "customer_id already resolves to a real account")
if len(matching_users) == 0:
return ("orphan", "no WordPress account matches this billing email")
if len(matching_users) > 1:
return ("ambiguous", "more than one account shares this billing email")
match = matching_users[0]
if match["id"] == order.get("customer_id"):
return ("skip", "already pointing at the matching account")
return ("remap", f"remap to user {match['id']}")
export function decide(order, currentCustomerValid, matchingUsers) {
// matchingUsers: WooCommerce customer objects whose email equals the
// order's billing email, each with at least { id, stripe_customer_id }.
if (currentCustomerValid) return ["skip", "customer_id already resolves to a real account"];
if (matchingUsers.length === 0) return ["orphan", "no WordPress account matches this billing email"];
if (matchingUsers.length > 1) return ["ambiguous", "more than one account shares this billing email"];
const match = matchingUsers[0];
if (match.id === order.customer_id) return ["skip", "already pointing at the matching account"];
return ["remap", `remap to user ${match.id}`];
}
Confirm with the Stripe customer id before writing
Email alone is a decent signal but people do change addresses and free mailboxes get reused. Before writing, cross check that the matched WordPress user's saved Stripe customer id (kept in the user's own _stripe_customer_id user meta, which most Stripe gateways set) either matches the order's Stripe customer id or is empty. If the two Stripe customer ids actively disagree, treat it the same as ambiguous rather than force the remap.
def stripe_ids_agree(order_stripe_customer_id, user_stripe_customer_id):
if not order_stripe_customer_id or not user_stripe_customer_id:
return True # nothing to contradict, email match stands
return order_stripe_customer_id == user_stripe_customer_id
export function stripeIdsAgree(orderStripeCustomerId, userStripeCustomerId) {
if (!orderStripeCustomerId || !userStripeCustomerId) return true; // nothing to contradict
return orderStripeCustomerId === userStripeCustomerId;
}
Remap the order and leave a note
When the action is remap, update the order's customer_id through the REST API and add an order note recording the old value, the new value, and the signals used, so a person reviewing the store later can see exactly what happened and why.
def remap_order(order, new_customer_id):
old_id = order.get("customer_id")
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"customer_id": new_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"Remapped customer_id from {old_id} to {new_customer_id} after the "
f"store move. Matched by Stripe customer id and billing email."},
auth=AUTH, timeout=30,
).raise_for_status()
async function remapOrder(order, newCustomerId) {
const oldId = order.customer_id;
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ customer_id: newCustomerId }),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Remapped customer_id from ${oldId} to ${newCustomerId} after the store move. ` +
`Matched by Stripe customer id and billing email.`,
}),
});
}
Always start with DRY_RUN=true. Remapping the wrong order to the wrong account is worse than leaving it broken, since it can expose one customer's order history to another. Review the full report, spot check a few orphan and ambiguous cases by hand, and only then turn dry run off.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every decision it makes, respects the dry run flag, and never remaps an order unless the Stripe customer id and the billing email agree on exactly one account.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Remap WooCommerce orders to the correct customer after a store move.
A migration usually re-creates WordPress users with new IDs while orders keep
their old numeric customer_id. This walks every order, finds the WordPress
user whose email matches the order's billing email, cross checks that user's
saved Stripe customer id against the order's Stripe customer id, and only
remaps when both signals agree on exactly one account. Safe to run again and
again; already-correct orders are always skipped.
"""
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("remap_customers")
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"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def all_orders():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"per_page": 50, "page": page, "orderby": "id", "order": "asc"},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
yield order
page += 1
def user_exists(customer_id):
if not customer_id:
return False
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}", auth=AUTH, timeout=30)
return r.status_code == 200
def users_by_email(email):
if not email:
return []
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 meta_value(order, key):
for meta in order.get("meta_data") or []:
if meta.get("key") == key and meta.get("value"):
return meta["value"]
return None
def stripe_customer_id_of_order(order):
direct = meta_value(order, "_stripe_customer_id")
if direct:
return direct
intent_id = meta_value(order, "_stripe_intent_id") or order.get("transaction_id")
if intent_id and intent_id.startswith("pi_"):
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
return intent.get("customer")
except stripe.error.InvalidRequestError:
return None
return None
def stripe_ids_agree(order_stripe_customer_id, user_stripe_customer_id):
if not order_stripe_customer_id or not user_stripe_customer_id:
return True # nothing to contradict, email match stands
return order_stripe_customer_id == user_stripe_customer_id
def decide(order, current_customer_valid, matching_users):
if current_customer_valid:
return ("skip", "customer_id already resolves to a real account")
if len(matching_users) == 0:
return ("orphan", "no WordPress account matches this billing email")
if len(matching_users) > 1:
return ("ambiguous", "more than one account shares this billing email")
match = matching_users[0]
if match["id"] == order.get("customer_id"):
return ("skip", "already pointing at the matching account")
return ("remap", f"remap to user {match['id']}")
def remap_order(order, new_customer_id):
old_id = order.get("customer_id")
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"customer_id": new_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"Remapped customer_id from {old_id} to {new_customer_id} after the "
f"store move. Matched by Stripe customer id and billing email."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
remapped = 0
reported = 0
for order in all_orders():
current_valid = user_exists(order.get("customer_id"))
email = (order.get("billing") or {}).get("email")
candidates = users_by_email(email) if not current_valid else []
action, reason = decide(order, current_valid, candidates)
if action == "skip":
continue
if action in ("orphan", "ambiguous"):
log.warning("Order %s: %s", order["id"], reason)
reported += 1
continue
match = candidates[0]
order_stripe_id = stripe_customer_id_of_order(order)
user_stripe_id = match.get("meta_data_stripe_customer_id") or None
if not stripe_ids_agree(order_stripe_id, user_stripe_id):
log.warning("Order %s: Stripe customer id disagrees with email match, skipping", order["id"])
reported += 1
continue
log.info("Order %s: %s. %s", order["id"], reason, "would remap" if DRY_RUN else "remapping")
if not DRY_RUN:
remap_order(order, match["id"])
remapped += 1
log.info(
"Done. %d order(s) %s, %d reported for manual review.",
remapped, "to remap" if DRY_RUN else "remapped", reported,
)
if __name__ == "__main__":
run()
/**
* Remap WooCommerce orders to the correct customer after a store move.
*
* A migration usually re-creates WordPress users with new IDs while orders
* keep their old numeric customer_id. This walks every order, finds the
* WordPress user whose email matches the order's billing email, cross
* checks that user's saved Stripe customer id against the order's Stripe
* customer id, and only remaps when both signals agree on exactly one
* account. Safe to run again and again; already-correct orders are skipped.
*/
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
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 || {}) },
});
return res;
}
async function* allOrders() {
let page = 1;
while (true) {
const res = await woo(`/orders?per_page=50&page=${page}&orderby=id&order=asc`);
if (!res.ok) throw new Error(`Woo /orders returned ${res.status}`);
const batch = await res.json();
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function userExists(customerId) {
if (!customerId) return false;
const res = await woo(`/customers/${customerId}`);
return res.status === 200;
}
async function usersByEmail(email) {
if (!email) return [];
const res = await woo(`/customers?email=${encodeURIComponent(email)}&per_page=10`);
if (!res.ok) throw new Error(`Woo /customers returned ${res.status}`);
return res.json();
}
function metaValue(order, key) {
for (const meta of order.meta_data || []) {
if (meta.key === key && meta.value) return meta.value;
}
return null;
}
async function stripeCustomerIdOfOrder(order) {
const direct = metaValue(order, "_stripe_customer_id");
if (direct) return direct;
const intentId = metaValue(order, "_stripe_intent_id") || order.transaction_id;
if (intentId && intentId.startsWith("pi_")) {
try {
const intent = await stripe.paymentIntents.retrieve(intentId);
return intent.customer || null;
} catch {
return null;
}
}
return null;
}
export function stripeIdsAgree(orderStripeCustomerId, userStripeCustomerId) {
if (!orderStripeCustomerId || !userStripeCustomerId) return true; // nothing to contradict
return orderStripeCustomerId === userStripeCustomerId;
}
export function decide(order, currentCustomerValid, matchingUsers) {
if (currentCustomerValid) return ["skip", "customer_id already resolves to a real account"];
if (matchingUsers.length === 0) return ["orphan", "no WordPress account matches this billing email"];
if (matchingUsers.length > 1) return ["ambiguous", "more than one account shares this billing email"];
const match = matchingUsers[0];
if (match.id === order.customer_id) return ["skip", "already pointing at the matching account"];
return ["remap", `remap to user ${match.id}`];
}
async function remapOrder(order, newCustomerId) {
const oldId = order.customer_id;
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ customer_id: newCustomerId }),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Remapped customer_id from ${oldId} to ${newCustomerId} after the store move. ` +
`Matched by Stripe customer id and billing email.`,
}),
});
}
export async function run() {
let remapped = 0;
let reported = 0;
for await (const order of allOrders()) {
const currentValid = await userExists(order.customer_id);
const email = (order.billing || {}).email;
const candidates = currentValid ? [] : await usersByEmail(email);
const [action, reason] = decide(order, currentValid, candidates);
if (action === "skip") continue;
if (action === "orphan" || action === "ambiguous") {
console.warn(`Order ${order.id}: ${reason}`);
reported++;
continue;
}
const match = candidates[0];
const orderStripeId = await stripeCustomerIdOfOrder(order);
const userStripeId = match.meta_data_stripe_customer_id || null;
if (!stripeIdsAgree(orderStripeId, userStripeId)) {
console.warn(`Order ${order.id}: Stripe customer id disagrees with email match, skipping`);
reported++;
continue;
}
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would remap" : "remapping"}`);
if (!DRY_RUN) await remapOrder(order, match.id);
remapped++;
}
console.log(`Done. ${remapped} order(s) ${DRY_RUN ? "to remap" : "remapped"}, ${reported} reported for manual review.`);
}
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 customer's order history gets touched. Because we kept decide and stripeIdsAgree pure, no network or Stripe account is needed. The tests just feed in plain objects and check the outcome.
from remap_customers import decide, stripe_ids_agree
def order(**over):
base = {"id": 482, "customer_id": 482}
base.update(over)
return base
def test_skip_when_current_customer_valid():
assert decide(order(), True, [])[0] == "skip"
def test_orphan_when_no_email_match():
assert decide(order(), False, [])[0] == "orphan"
def test_ambiguous_when_multiple_email_matches():
users = [{"id": 219}, {"id": 340}]
assert decide(order(), False, users)[0] == "ambiguous"
def test_remap_when_exactly_one_match_and_id_differs():
users = [{"id": 219}]
action, reason = decide(order(), False, users)
assert action == "remap"
assert "219" in reason
def test_skip_when_single_match_already_correct():
users = [{"id": 482}]
assert decide(order(customer_id=482), False, users)[0] == "skip"
def test_stripe_ids_agree_when_either_missing():
assert stripe_ids_agree(None, "cus_123") is True
assert stripe_ids_agree("cus_123", None) is True
def test_stripe_ids_agree_when_equal():
assert stripe_ids_agree("cus_123", "cus_123") is True
def test_stripe_ids_disagree_when_different():
assert stripe_ids_agree("cus_123", "cus_999") is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, stripeIdsAgree } from "./remap-customers.js";
const order = (over = {}) => ({ id: 482, customer_id: 482, ...over });
test("skip when current customer valid", () => {
assert.equal(decide(order(), true, [])[0], "skip");
});
test("orphan when no email match", () => {
assert.equal(decide(order(), false, [])[0], "orphan");
});
test("ambiguous when multiple email matches", () => {
const users = [{ id: 219 }, { id: 340 }];
assert.equal(decide(order(), false, users)[0], "ambiguous");
});
test("remap when exactly one match and id differs", () => {
const users = [{ id: 219 }];
const [action, reason] = decide(order(), false, users);
assert.equal(action, "remap");
assert.ok(reason.includes("219"));
});
test("skip when single match already correct", () => {
const users = [{ id: 482 }];
assert.equal(decide(order({ customer_id: 482 }), false, users)[0], "skip");
});
test("stripeIdsAgree when either missing", () => {
assert.equal(stripeIdsAgree(null, "cus_123"), true);
assert.equal(stripeIdsAgree("cus_123", null), true);
});
test("stripeIdsAgree when equal", () => {
assert.equal(stripeIdsAgree("cus_123", "cus_123"), true);
});
test("stripeIdsAgree disagree when different", () => {
assert.equal(stripeIdsAgree("cus_123", "cus_999"), false);
});
Case studies
The move that renumbered everyone
A store moved hosts using a generic WordPress migration plugin. The plugin handled posts and pages cleanly but rebuilt the users table with new auto-increment IDs. Every one of the six thousand existing orders kept its old customer_id, so almost no returning customer could see their order history after logging in.
The script ran in dry run first and reported the shape of the damage: about ninety four percent of orders had a clean single email match and were safe to remap, while the rest were split between orphans (email no longer existed at all) and a small number of ambiguous cases from shared family email addresses. The clean majority was remapped in one real run, and the smaller reported lists were handled by hand.
Two shops, one customer_id, two different people
A brand merged two regional WooCommerce shops into one. Both old sites had, by coincidence, a customer with id 88. After the merge, one region's order history silently showed up under the wrong region's account with the same id.
Because the script cross checked the Stripe customer id in addition to the email, the collision was caught immediately as an "ambiguous" case rather than silently applied, since the two real Stripe customers behind that shared id number disagreed. The team split the merge into two passes, one per region, and reran cleanly.
After this runs, every order whose true owner can be confirmed by two independent signals is reattached to the right account, and everything else is on a short, reviewable list instead of hidden inside six thousand silently wrong orders. Keep the script handy for any future migration, since the same old-id, new-id gap happens on almost every store move.
FAQ
Why do orders lose their customer after a WooCommerce store move?
A migration usually imports users as new WordPress accounts with new IDs. Orders still hold the old numeric customer_id, so they point at a user that either does not exist on the new site or belongs to someone else. Stripe's customer id, which is stored separately in order meta, still points at the right person, so it can be used to remap the order to the correct account.
Is it safe to change the customer_id on an order with a script?
Yes, as long as the script only acts when it can confirm the match through more than one signal, typically the Stripe customer id plus the billing email agreeing on a single WordPress user. Orders where the signals disagree or point at more than one candidate are left alone and reported instead. Start in dry run mode to review the plan before it writes.
What if two customers share the same email address after the move?
Treat that as an ambiguous case, not an automatic fix. The script should skip any order where the email matches more than one WordPress user and report it separately, so a person can pick the right account instead of the script guessing.
Related field notes
Citations
On the problem:
- WooCommerce docs: importing and exporting orders, and why customer references are not portable between sites. woocommerce.com/document/product-csv-importer-exporter
- WordPress support: user IDs are assigned per install and are not guaranteed to match after a migration or restore. wordpress.org/documentation/article/users-screen
- WooCommerce docs: how the Stripe gateway saves the Stripe customer id on orders and user accounts. woocommerce.com/document/stripe
On the solution:
- WooCommerce REST API: list and update orders, including the customer_id field. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: list customers and filter by email. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent and read its associated customer. 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.
Did this fix your migration mess?
If this saved you from a pile of "where are my orders" tickets after a move, 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