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.
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.
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:
- WooCommerce sets
customer_idonce, at order creation, and never revisits it later even if the shopper creates an account afterward with the same email. - Guest checkout is often the default and fastest path, so a large share of first time buyers never log in even when they already have an account from a previous visit.
- Marketing and loyalty plugins that key off
customer_idsilently skip these orders, so lifetime value, reward points, and repeat purchase reports all undercount the same shoppers. - Merging by name is unreliable, since names are typed inconsistently, but the billing email is normalized and is the one field checkout and account registration both share.
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 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.
Build it step by step
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.
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
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
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.
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
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++;
}
}
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.
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()
async function findCustomersByEmail(email) {
return woo(`/customers?email=${encodeURIComponent(email)}&per_page=10`);
}
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_.
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
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;
}
}
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.
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']}")
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}`];
}
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.
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 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 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.
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"
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
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.
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.
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:
- WooCommerce REST API docs: orders have a customer_id field and can be filtered by customer. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce docs: guest checkout and how account creation at checkout works. woocommerce.com/document/managing-orders
- 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:
- WooCommerce REST API: list and filter customers, including by email. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: update an order and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
- 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.
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