Diagnostic WooCommerce core: database bloat and maintenance
The WooCommerce Clear transients tool leaves residue behind
You ran Status, Tools, Clear transients because wp_options was getting heavy. The count went down, the page said it worked, and for most stores that is the end of the story. But a few rows can survive the sweep with no partner left to match them, and one of those leftovers is a cached Stripe payment status that stops updating. The order looks fine in the list. Stripe disagrees. Here is why the tool misses a few rows and a small script that finds every order the residue is hiding.
The Clear transients tool deletes a transient's value row and its timeout row with one query, but they were written as two separate inserts, so a cut short request can leave one behind without the other. WooCommerce's own cache code skips a row with no timeout partner, so that leftover value goes stale and is never touched again, including a cached Stripe payment status hanging off an order. Run a small Python or Node.js check on a schedule that reads the real PaymentIntent status from Stripe for recent orders and repairs any order whose status disagrees with it. Full code, tests, and a dry run guard are below.
The problem in plain words
A WordPress transient is really two rows in wp_options: one named _transient_your_key holding the cached value, and one named _transient_timeout_your_key holding when it expires. They are always written together and read together. WooCommerce uses transients like this all over the place, including caching pieces of data tied to a Stripe payment so it does not have to call the Stripe API on every page load.
The Clear transients tool under Status, Tools runs a bulk delete that matches both row names at once. That works cleanly when both rows are still there. It does not work cleanly when only one of them is, because a half written transient, one row present and its partner already gone from an earlier crash, a plugin conflict, or a timed out request, does not look like a normal expired transient to the query, and can be skipped or only half removed. What is left behind is an orphaned row with a stale value and no way for WooCommerce to know it is stale.
Why it happens
WordPress core documents transients as a pair of options that are meant to be written and cleaned up together. A few common reasons the pair splits apart before the cleanup tool ever runs:
- A request that was setting or refreshing the transient got cut off by a PHP timeout or a memory limit after writing one row but before writing the other.
- A caching or object cache plugin intercepts transient calls and only mirrors part of the pair back to the database, so what is in
wp_optionsno longer matches what WordPress thinks is there. - A second admin clicked Clear transients while the first click was still running, and the two bulk deletes overlapped on the same rows.
- An older WooCommerce Stripe integration wrote a cached PaymentIntent status transient directly, bypassing the normal
set_transient()helper, and never gave it a matching timeout row at all.
None of this shows up as an error. The store just quietly keeps a cached Stripe status on an order that stopped updating the day the pairing broke. The order sits in whatever state it was cached in, right up until someone compares it to Stripe by hand and finds the two disagree.
A stale cache is not a database problem you can safely fix with the same tool that made it stale. Deleting the leftover wp_options row does not tell you whether the order it was caching for is now wrong. Stripe is the source of truth for a payment. Read the PaymentIntent status straight from Stripe for the orders that matter and compare it to what the order shows, that is the only way to know which orders the residue actually affected.
The fix, as a flow
We do not try to hand pick orphaned rows out of wp_options, since the WooCommerce REST API has no endpoint for that and guessing at raw rows is how stores end up losing an unrelated cache by mistake. Instead we go straight to the thing the cache was standing in for. For each recent order we read the saved PaymentIntent id, ask Stripe what it actually reports right now, and compare that to the order's own status. When they disagree and the amount still matches, we bring the order in line with Stripe, the same way a fresh, unstale cache would have.
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. 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="7"
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="7"
export DRY_RUN="true" // start safe, change to false to write
Read the PaymentIntent id straight off the order
WooCommerce's Stripe gateway saves the PaymentIntent id as order meta under _stripe_intent_id. Some older orders only have it in transaction_id instead. Check both, and only trust a transaction_id that actually looks like a PaymentIntent id, since that field can also hold a charge id on very old orders.
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
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;
}
List recent orders through the REST API
We only need orders from the last week or two, since older residue has usually already been noticed one way or another. Going through the WooCommerce REST API means the code works the same whether the store has High Performance Order Storage (HPOS) turned on or not.
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"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
def recent_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={"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 || 7);
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* recentOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
Decide, with one pure function
Keep the decision in its own function that takes an order and a Stripe intent and returns an action. A pure function like this is easy to read and easy to test, which we do later. If the order's status already agrees with Stripe, skip it. If Stripe says the payment finished but the order was left unpaid, or Stripe says it failed but the order was left marked paid, repair it, as long as the amount still matches. If the amount does not match, skip it and let a person look, since that could be a different problem entirely.
LIVE_STATUSES = {"pending", "on-hold", "processing", "completed"}
PAID_STATUSES = {"processing", "completed"}
UNPAID_STATUSES = {"pending", "on-hold"}
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(order, intent):
if order["status"] not in LIVE_STATUSES:
return ("skip", "order status is not one the cache tracks")
if intent is None:
return ("orphan", "no PaymentIntent id saved on the order")
if intent.get("status") == "succeeded":
if order["status"] in UNPAID_STATUSES:
if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
return ("skip", "amount does not match, needs a human look")
return ("repair", "Stripe succeeded but the stale cache left the order unpaid")
return ("skip", "already matches a succeeded charge")
if intent.get("status") in ("canceled", "requires_payment_method"):
if order["status"] in PAID_STATUSES:
return ("repair", "order is marked paid but the stale cache missed a failure or cancellation")
return ("skip", "both sides agree the payment did not complete")
return ("skip", "intent is still in progress, nothing stale to repair yet")
const LIVE_STATUSES = new Set(["pending", "on-hold", "processing", "completed"]);
const PAID_STATUSES = new Set(["processing", "completed"]);
const UNPAID_STATUSES = new Set(["pending", "on-hold"]);
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
export function decide(order, intent) {
if (!LIVE_STATUSES.has(order.status)) {
return ["skip", "order status is not one the cache tracks"];
}
if (!intent) return ["orphan", "no PaymentIntent id saved on the order"];
if (intent.status === "succeeded") {
if (UNPAID_STATUSES.has(order.status)) {
if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
return ["skip", "amount does not match, needs a human look"];
}
return ["repair", "Stripe succeeded but the stale cache left the order unpaid"];
}
return ["skip", "already matches a succeeded charge"];
}
if (intent.status === "canceled" || intent.status === "requires_payment_method") {
if (PAID_STATUSES.has(order.status)) {
return ["repair", "order is marked paid but the stale cache missed a failure or cancellation"];
}
return ["skip", "both sides agree the payment did not complete"];
}
return ["skip", "intent is still in progress, nothing stale to repair yet"];
}
Repair the order and leave a note
When the action is repair, set the order status to match what Stripe reports and add a note explaining why, so the shop manager can see this was an automatic catch up, not a random change. Both the status update and the note go through the REST API, so HPOS is handled for you.
def repair(order, intent, reason):
new_status = "processing" if intent.get("status") == "succeeded" else "on-hold"
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"status": new_status},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Transient residue repair: {reason}. Stripe PaymentIntent "
f"{intent['id']} now reports {intent.get('status')}. Order moved "
f"to {new_status} to match. A stale cache row left behind by the "
f"Clear transients tool likely hid this."},
auth=AUTH, timeout=30,
).raise_for_status()
async function repair(order, intent, reason) {
const newStatus = intent.status === "succeeded" ? "processing" : "on-hold";
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ status: newStatus }),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Transient residue repair: ${reason}. Stripe PaymentIntent ${intent.id} ` +
`now reports ${intent.status}. Order moved to ${newStatus} to match. A stale ` +
`cache row left behind by the Clear transients tool likely hid this.`,
}),
});
}
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 reports what it would do. Read the output, trust it, then switch it off to let it write. Run it once right after anyone uses the Clear transients tool, and on a weekly schedule after that.
Always start with DRY_RUN=true. This script writes to real order statuses, so you want to see its plan before it acts. Once the report looks right for a run or two, turn it off.
The full code
Here is the complete check and repair job 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 touches orders whose cached status actually disagrees with what Stripe reports.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Repair orders whose Stripe status went stale after the Clear Transients tool ran.
WooCommerce Status, Tools, Clear transients deletes the wp_options rows for
`_transient_wc_*` and their `_transient_timeout_wc_*` partners. The tool matches both
names with one LIKE query, but WordPress writes the timeout row and the value row as
two separate INSERTs. If a request is killed between them (a timeout, a memory limit,
a second click on the same button), one row survives without its partner. That
surviving row is residue: WooCommerce's own transient get/set calls skip a row with no
timeout, so the cache never refreshes itself and quietly goes stale forever.
The customer facing version of this is a PaymentIntent status cached in order meta
that stops following the intent once its backing transient is half deleted. This walks
recent orders, reads the saved PaymentIntent id, and flags (or repairs) any order whose
cached status disagrees with what Stripe reports right now. Safe by default. Run on a
schedule after anyone runs the clear transients tool, or as a weekly check.
"""
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("repair_transient_residue")
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", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Order statuses where the cached payment state actually matters.
LIVE_STATUSES = {"pending", "on-hold", "processing", "completed"}
# What each Woo order status implies the cached payment state should be.
PAID_STATUSES = {"processing", "completed"}
UNPAID_STATUSES = {"pending", "on-hold"}
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):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# have their own guide, since 50.00 is wrong for those.
return round(float(order["total"]) * 100)
def decide(order, intent):
"""Pure decision function. No network calls, no side effects.
Returns a tuple of (action, reason). action is one of:
skip - nothing to check, or already agrees with Stripe
orphan - the order has no PaymentIntent id to check against
repair - the order status disagrees with what Stripe reports now
"""
if order["status"] not in LIVE_STATUSES:
return ("skip", "order status is not one the cache tracks")
if intent is None:
return ("orphan", "no PaymentIntent id saved on the order")
if intent.get("status") == "succeeded":
if order["status"] in UNPAID_STATUSES:
if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
return ("skip", "amount does not match, needs a human look")
return ("repair", "Stripe succeeded but the stale cache left the order unpaid")
return ("skip", "already matches a succeeded charge")
if intent.get("status") in ("canceled", "requires_payment_method"):
if order["status"] in PAID_STATUSES:
return ("repair", "order is marked paid but the stale cache missed a failure or cancellation")
return ("skip", "both sides agree the payment did not complete")
return ("skip", "intent is still in progress, nothing stale to repair yet")
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 recent_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={"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 repair(order, intent, reason):
new_status = "processing" if intent.get("status") == "succeeded" else "on-hold"
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"status": new_status},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Transient residue repair: {reason}. Stripe PaymentIntent "
f"{intent['id']} now reports {intent.get('status')}. Order moved "
f"to {new_status} to match. A stale cache row left behind by the "
f"Clear transients tool likely hid this."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
orphans = 0
for order in recent_orders():
intent_id = intent_id_of(order)
intent = get_intent(intent_id)
action, reason = decide(order, intent)
if action == "orphan":
orphans += 1
log.warning("Order %s: %s", order["id"], reason)
continue
if action == "skip":
continue
log.info("Order %s: %s. %s", order["id"], reason, "would repair" if DRY_RUN else "repairing")
if not DRY_RUN:
repair(order, intent, reason)
fixed += 1
log.info("Done. %d order(s) %s, %d orphan(s) with no PaymentIntent id.",
fixed, "to repair" if DRY_RUN else "repaired", orphans)
if __name__ == "__main__":
run()
/**
* Repair orders whose Stripe status went stale after the Clear Transients tool ran.
*
* WooCommerce Status, Tools, Clear transients deletes the wp_options rows for
* `_transient_wc_*` and their `_transient_timeout_wc_*` partners. The tool matches
* both names with one LIKE query, but WordPress writes the timeout row and the value
* row as two separate INSERTs. If a request is killed between them (a timeout, a
* memory limit, a second click on the same button), one row survives without its
* partner. That surviving row is residue: WooCommerce's own transient get/set calls
* skip a row with no timeout, so the cache never refreshes itself and quietly goes
* stale forever.
*
* The customer facing version of this is a PaymentIntent status cached in order meta
* that stops following the intent once its backing transient is half deleted. This
* walks recent orders, reads the saved PaymentIntent id, and flags (or repairs) any
* order whose cached status disagrees with what Stripe reports right now. Safe by
* default. Run on a schedule after anyone runs the clear transients tool, or as a
* weekly check.
*/
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 || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const LIVE_STATUSES = new Set(["pending", "on-hold", "processing", "completed"]);
const PAID_STATUSES = new Set(["processing", "completed"]);
const UNPAID_STATUSES = new Set(["pending", "on-hold"]);
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);
}
export function decide(order, intent) {
if (!LIVE_STATUSES.has(order.status)) {
return ["skip", "order status is not one the cache tracks"];
}
if (!intent) return ["orphan", "no PaymentIntent id saved on the order"];
if (intent.status === "succeeded") {
if (UNPAID_STATUSES.has(order.status)) {
if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
return ["skip", "amount does not match, needs a human look"];
}
return ["repair", "Stripe succeeded but the stale cache left the order unpaid"];
}
return ["skip", "already matches a succeeded charge"];
}
if (intent.status === "canceled" || intent.status === "requires_payment_method") {
if (PAID_STATUSES.has(order.status)) {
return ["repair", "order is marked paid but the stale cache missed a failure or cancellation"];
}
return ["skip", "both sides agree the payment did not complete"];
}
return ["skip", "intent is still in progress, nothing stale to repair yet"];
}
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* recentOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function repair(order, intent, reason) {
const newStatus = intent.status === "succeeded" ? "processing" : "on-hold";
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ status: newStatus }),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Transient residue repair: ${reason}. Stripe PaymentIntent ${intent.id} ` +
`now reports ${intent.status}. Order moved to ${newStatus} to match. A stale ` +
`cache row left behind by the Clear transients tool likely hid this.`,
}),
});
}
export async function run() {
let fixed = 0;
let orphans = 0;
for await (const order of recentOrders()) {
const intent = await getIntent(intentIdOf(order));
const [action, reason] = decide(order, intent);
if (action === "orphan") {
orphans++;
console.warn(`Order ${order.id}: ${reason}`);
continue;
}
if (action === "skip") continue;
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
if (!DRY_RUN) await repair(order, intent, reason);
fixed++;
}
console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to repair" : "repaired"}, ${orphans} orphan(s) with no PaymentIntent id.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which orders get their status changed. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from repair_transient_residue import decide, intent_id_of, order_amount_minor
def intent(**over):
base = {"status": "succeeded", "amount_received": 5000, "id": "pi_1"}
base.update(over)
return base
def test_repair_when_succeeded_but_order_left_unpaid():
order = {"status": "pending", "total": "50.00"}
assert decide(order, intent())[0] == "repair"
def test_skip_when_succeeded_and_already_processing():
order = {"status": "processing", "total": "50.00"}
assert decide(order, intent())[0] == "skip"
def test_repair_when_canceled_but_order_left_processing():
order = {"status": "processing", "total": "50.00"}
assert decide(order, intent(status="canceled"))[0] == "repair"
def test_orphan_when_no_intent_id():
order = {"status": "processing", "total": "50.00"}
assert decide(order, None)[0] == "orphan"
def test_intent_id_from_meta():
order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
assert intent_id_of(order) == "pi_123"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./repair-transient-residue.js";
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, id: "pi_1", ...over });
test("repair when succeeded but order left unpaid", () => {
assert.equal(decide({ status: "pending", total: "50.00" }, intent())[0], "repair");
});
test("skip when succeeded and already processing", () => {
assert.equal(decide({ status: "processing", total: "50.00" }, intent())[0], "skip");
});
test("repair when canceled but order left processing", () => {
assert.equal(decide({ status: "processing", total: "50.00" }, intent({ status: "canceled" }))[0], "repair");
});
test("orphan when no intent id", () => {
assert.equal(decide({ status: "processing", total: "50.00" }, null)[0], "orphan");
});
test("intentIdOf from meta", () => {
assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});
Case studies
The plugin audit that woke up old orders
A store owner ran the Clear transients tool as part of a general plugin cleanup after swapping caching plugins. A week later, three orders from months earlier that had been quietly cached as pending suddenly needed a look, because the residue check found Stripe had actually succeeded on all three and no one had ever noticed.
Running the check in dry run first showed the exact three orders with their Stripe status side by side with the WooCommerce status, which made it an easy decision to repair them.
The host that killed a long running request
A budget host enforced a strict PHP execution limit. A background job that was mid write to a Stripe status transient got killed by the limit, leaving one row of the pair behind. The order it belonged to kept showing on-hold long after the customer's card had actually been declined and the PaymentIntent had moved to canceled.
The weekly scheduled check caught the mismatch and moved the order to on-hold to match Stripe, then the note made it clear to support why the order looked stuck for so long.
After this runs on a schedule, a half deleted transient is no longer a silent source of wrong order statuses. The worst case becomes a short delay before the check catches up an order to what Stripe actually reports. Keep the Clear transients tool for what it is good at, general housekeeping, and let this check be the safety net for the handful of rows it does not fully clean up.
FAQ
Why does the WooCommerce Clear transients tool not fully clean up?
The tool deletes a transient's value row and its timeout row with one query, but WordPress writes those two rows as separate inserts when the transient was first created. If a request was ever cut short partway through, one row can exist without its partner. WooCommerce's cache functions skip a row with no timeout, so that leftover value never gets reused or refreshed again.
Is it safe to repair an order status with a script after this happens?
Yes, when the script confirms the current PaymentIntent status from Stripe and only changes an order whose status disagrees with that truth, and it skips any order where the amount does not match so a person can look at it. Start in dry run mode to review the list before it writes anything.
How often should I run the residue check?
Run it once after anyone uses the Clear transients tool, and on a weekly schedule after that as a general health check. It only touches orders whose cached status disagrees with Stripe, so running it often is safe and cheap.
Related field notes
Citations
On the problem:
- WordPress Developer Resources: Transients API, how a transient is stored as a value option and a timeout option. developer.wordpress.org/apis/transients
- WooCommerce docs: Status, Tools page and what the Clear transients action does. woocommerce.com/document/woocommerce-status-report
- WordPress Trac: reports of orphaned transient rows surviving cleanup when a request is interrupted. core.trac.wordpress.org/ticket/56064
On the solution:
- Stripe API: retrieve a PaymentIntent to read its current, authoritative status. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: update an order and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce Stripe gateway source: where the PaymentIntent id is saved as order meta. github.com/woocommerce/woocommerce-gateway-stripe
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 catch some hidden residue for you?
If this saved you a confusing afternoon of comparing orders against Stripe by hand, 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