Repair Account and store migration
Stale Stripe PaymentIntent IDs after a WooCommerce gateway switch
You moved to a new Stripe account, switched Stripe from test mode to live, or replaced the gateway entirely. The new payments work fine. But the moment someone tries to refund an old order, renew a subscription, or run a sync job against it, Stripe answers with "No such payment_intent". The order is not broken, it is just holding onto an ID from a account that no longer applies. Here is why that happens and a small script that finds every order with a stale ID and clears it safely.
After a gateway switch, old orders keep the previous Stripe PaymentIntent id in meta _stripe_intent_id or transaction_id. That id does not exist under the new secret key, so any later call that uses it fails with No such payment_intent. Run a small Python or Node.js script that tries to resolve each saved id against the current Stripe account, and clears the id only on orders that are already finished and whose id truly does not resolve. Full code, tests, and a dry run guard are below.
The problem in plain words
Every WooCommerce order that goes through Stripe saves a reference back to the charge, usually the PaymentIntent id, in the order's meta. That reference is how later actions, a refund, a renewal, a reconciliation job, find their way back to the right Stripe object.
That reference only means something inside one Stripe account and one Stripe mode. When you switch to a new Stripe account, move a subscriptions business off an agency's account, or flip a store from test keys to live keys, the secret key on the server changes but the old orders do not. They still point at PaymentIntent IDs that live in the account you left behind. Ask the new key about that ID and Stripe has never heard of it.
Why it happens
PaymentIntent and Charge IDs are scoped to one Stripe account and one mode, test or live. Nothing in WooCommerce automatically knows that the gateway underneath it changed, so it never goes back to clean up old references. A few ways this shows up in practice:
- A store moves from an agency's Stripe account to the merchant's own account. All new charges use the new account, but every historical order still has the agency account's PaymentIntent IDs saved.
- A developer tests checkout with Stripe test keys, then flips to live keys for launch. Orders created during testing keep test-mode IDs, which live keys can never see.
- A store replaces the WooCommerce Stripe gateway with a different payment gateway altogether, but keeps the old orders instead of archiving them, so the dead references remain in the database.
- A staging site is cloned from production, then later reconnected to a different Stripe account than the one production uses, silently orphaning every reference on the clone.
The Stripe docs are explicit that objects and their IDs are scoped per account, so an ID from one account is meaningless in another. WooCommerce forums also show recurring reports of the same "No such payment_intent" error right after a merchant reports switching Stripe accounts or providers. See the citations at the end for both.
A stale ID is not a payment problem, it is a bookkeeping problem. The money was captured just fine under the old account. The only thing wrong is that the order still points somewhere the new key cannot follow. Once you clear that dead pointer off finished orders, the new gateway has nothing old to trip over.
The fix, as a flow
We do not touch payments and we do not touch orders that are still waiting to be paid. We add a script that walks recent orders, asks the current Stripe account whether it recognizes each saved PaymentIntent id, and only clears the id on orders that already finished, when Stripe confirms the id truly does not exist. Every other order, whether the id resolves fine or the order is still pending, is left exactly as it was.
Build it step by step
Get access to the current gateway and to WooCommerce
You need the Stripe secret key for the account the store uses now, the new one, and a WooCommerce REST API key pair 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_..." # the CURRENT gateway's key
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="90"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..." // the CURRENT gateway's key
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="90"
export DRY_RUN="true" // start safe, change to false to write
Read the saved PaymentIntent id off each order
The id can live in the order meta key _stripe_intent_id, or as a fallback in transaction_id when it starts with the PaymentIntent prefix pi_. A charge id starting with ch_ in transaction_id is a different kind of object and is not what we are clearing here.
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;
}
Ask the current Stripe account to resolve the id
Try to retrieve the PaymentIntent by id. If Stripe answers, the id is fine and belongs to this account. If Stripe raises an invalid request error with the code resource_missing, the id does not exist here, which is the signature of a stale reference left by a gateway switch. Any other error, a timeout or a rate limit, is not a verdict and should not be treated as stale.
def lookup_intent(intent_id):
"""Ask Stripe about an id. Returns "no_id", "resolved", or "not_found"."""
if not intent_id:
return "no_id"
try:
stripe.PaymentIntent.retrieve(intent_id)
return "resolved"
except stripe.error.InvalidRequestError as exc:
code = getattr(exc, "code", None)
if code == "resource_missing" or getattr(exc, "http_status", None) == 404:
return "not_found"
raise
async function lookupIntent(intentId) {
if (!intentId) return "no_id";
try {
await stripe.paymentIntents.retrieve(intentId);
return "resolved";
} catch (err) {
if (err.code === "resource_missing" || err.statusCode === 404) return "not_found";
throw err;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the order and the lookup result and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If the order is not finished yet, leave it alone, the id might still matter. If there is no id saved, there is nothing to clear. If the id resolves, it is not stale. Only when the order is finished and the id genuinely does not resolve do we clear it.
FINISHED_STATUSES = {"processing", "completed", "refunded", "on-hold"}
def decide(order, lookup_result):
"""Pure decision. lookup_result is one of:
"resolved" the id was found in the current Stripe account
"not_found" Stripe returned resource_missing for the id
"no_id" the order has no saved PaymentIntent id at all
"""
if order["status"] not in FINISHED_STATUSES:
return ("skip", "order is not yet finished, leave the id alone")
if lookup_result == "no_id":
return ("skip", "no PaymentIntent id saved on this order")
if lookup_result == "resolved":
return ("skip", "id resolves fine in the current Stripe account")
if lookup_result == "not_found":
return ("clear", "id does not exist in the current Stripe account, stale from a gateway switch")
return ("skip", "unknown lookup result")
const FINISHED_STATUSES = new Set(["processing", "completed", "refunded", "on-hold"]);
/**
* Pure decision. lookupResult is one of:
* "resolved" the id was found in the current Stripe account
* "not_found" Stripe returned resource_missing for the id
* "no_id" the order has no saved PaymentIntent id at all
*/
export function decide(order, lookupResult) {
if (!FINISHED_STATUSES.has(order.status)) {
return ["skip", "order is not yet finished, leave the id alone"];
}
if (lookupResult === "no_id") return ["skip", "no PaymentIntent id saved on this order"];
if (lookupResult === "resolved") return ["skip", "id resolves fine in the current Stripe account"];
if (lookupResult === "not_found") {
return ["clear", "id does not exist in the current Stripe account, stale from a gateway switch"];
}
return ["skip", "unknown lookup result"];
}
Clear the stale id and leave a note
When the action is clear, blank out transaction_id and the _stripe_intent_id meta on the order, then add an order note that records the old id for anyone who needs to look it up in the previous account later. The order's status, totals, and history are never touched, only the dead reference is removed.
def clear_stale_id(order, old_intent_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={
"transaction_id": "",
"meta_data": [{"key": "_stripe_intent_id", "value": ""}],
},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Cleared a stale Stripe PaymentIntent id ({old_intent_id}) left over from a "
f"gateway switch. This id does not exist in the current Stripe account, so it "
f"was removed to stop future actions on this order from failing."},
auth=AUTH, timeout=30,
).raise_for_status()
async function clearStaleId(order, oldIntentId) {
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({
transaction_id: "",
meta_data: [{ key: "_stripe_intent_id", value: "" }],
}),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Cleared a stale Stripe PaymentIntent id (${oldIntentId}) left over from a gateway ` +
`switch. This id does not exist in the current Stripe account, so it was removed to ` +
`stop future actions on this order from failing.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would clear. Read the output, trust it, then switch it off to let it write. This is normally a one time cleanup right after a gateway switch, though it is safe to run again if you keep discovering old orders.
Always start with DRY_RUN=true. Clearing an id is easy to undo if you keep the old id in the order note, but it is still a write to real orders, so review the plan first.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches an order that is not yet finished or whose id already resolves.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Clear stale Stripe PaymentIntent IDs left behind after a gateway switch.
When a store moves to a new Stripe account, a new Stripe mode (test to live),
or a different payment gateway entirely, old orders keep the previous
PaymentIntent id in meta `_stripe_intent_id` (or `transaction_id`). That id
does not exist under the new secret key. Any later action that reads it,
a refund, a renewal charge, a sync job, fails with a Stripe "No such
payment_intent" error, even though the order itself is fine.
This walks recent orders, tries to resolve the saved id against the current
Stripe account, and clears the stale meta (and adds a note) on orders whose
id cannot be resolved and whose payment already finished. It never touches
an order whose id resolves fine, and it never touches an order that is
still waiting on payment. Read only by default. Run once after a gateway
switch, or on a schedule while you clean up the backlog.
"""
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("clear_stale_intent_ids")
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", "90"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
FINISHED_STATUSES = {"processing", "completed", "refunded", "on-hold"}
UNRESOLVED_ERROR_CODES = {"resource_missing"}
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 decide(order, lookup_result):
"""Pure decision. lookup_result is one of:
"resolved" the id was found in the current Stripe account
"not_found" Stripe returned resource_missing for the id
"no_id" the order has no saved PaymentIntent id at all
"""
if order["status"] not in FINISHED_STATUSES:
return ("skip", "order is not yet finished, leave the id alone")
if lookup_result == "no_id":
return ("skip", "no PaymentIntent id saved on this order")
if lookup_result == "resolved":
return ("skip", "id resolves fine in the current Stripe account")
if lookup_result == "not_found":
return ("clear", "id does not exist in the current Stripe account, stale from a gateway switch")
return ("skip", "unknown lookup result")
def lookup_intent(intent_id):
"""Ask Stripe about an id. Returns "no_id", "resolved", or "not_found"."""
if not intent_id:
return "no_id"
try:
stripe.PaymentIntent.retrieve(intent_id)
return "resolved"
except stripe.error.InvalidRequestError as exc:
code = getattr(exc, "code", None)
if code in UNRESOLVED_ERROR_CODES or getattr(exc, "http_status", None) == 404:
return "not_found"
raise
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 clear_stale_id(order, old_intent_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={
"transaction_id": "",
"meta_data": [{"key": "_stripe_intent_id", "value": ""}],
},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Cleared a stale Stripe PaymentIntent id ({old_intent_id}) left over from a "
f"gateway switch. This id does not exist in the current Stripe account, so it "
f"was removed to stop future actions on this order from failing."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
cleared = 0
for order in recent_orders():
old_intent_id = intent_id_of(order)
lookup_result = lookup_intent(old_intent_id)
action, reason = decide(order, lookup_result)
if action != "clear":
continue
log.warning("Order %s: %s. %s", order["id"], reason, "would clear" if DRY_RUN else "clearing")
if not DRY_RUN:
clear_stale_id(order, old_intent_id)
cleared += 1
log.info("Done. %d order(s) %s.", cleared, "to clear" if DRY_RUN else "cleared")
if __name__ == "__main__":
run()
/**
* Clear stale Stripe PaymentIntent IDs left behind after a gateway switch.
*
* When a store moves to a new Stripe account, a new Stripe mode (test to
* live), or a different payment gateway entirely, old orders keep the
* previous PaymentIntent id in meta `_stripe_intent_id` (or
* `transaction_id`). That id does not exist under the new secret key. Any
* later action that reads it, a refund, a renewal charge, a sync job, fails
* with a Stripe "No such payment_intent" error, even though the order
* itself is fine.
*
* This walks recent orders, tries to resolve the saved id against the
* current Stripe account, and clears the stale meta (and adds a note) on
* orders whose id cannot be resolved and whose payment already finished.
* It never touches an order whose id resolves fine, and it never touches
* an order that is still waiting on payment. Read only 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 || 90);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const FINISHED_STATUSES = new Set(["processing", "completed", "refunded", "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;
}
/**
* Pure decision. lookupResult is one of:
* "resolved" the id was found in the current Stripe account
* "not_found" Stripe returned resource_missing for the id
* "no_id" the order has no saved PaymentIntent id at all
*/
export function decide(order, lookupResult) {
if (!FINISHED_STATUSES.has(order.status)) {
return ["skip", "order is not yet finished, leave the id alone"];
}
if (lookupResult === "no_id") return ["skip", "no PaymentIntent id saved on this order"];
if (lookupResult === "resolved") return ["skip", "id resolves fine in the current Stripe account"];
if (lookupResult === "not_found") {
return ["clear", "id does not exist in the current Stripe account, stale from a gateway switch"];
}
return ["skip", "unknown lookup result"];
}
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 lookupIntent(intentId) {
if (!intentId) return "no_id";
try {
await stripe.paymentIntents.retrieve(intentId);
return "resolved";
} catch (err) {
if (err.code === "resource_missing" || err.statusCode === 404) return "not_found";
throw err;
}
}
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 clearStaleId(order, oldIntentId) {
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({
transaction_id: "",
meta_data: [{ key: "_stripe_intent_id", value: "" }],
}),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Cleared a stale Stripe PaymentIntent id (${oldIntentId}) left over from a gateway ` +
`switch. This id does not exist in the current Stripe account, so it was removed to ` +
`stop future actions on this order from failing.`,
}),
});
}
export async function run() {
let cleared = 0;
for await (const order of recentOrders()) {
const oldIntentId = intentIdOf(order);
const lookupResult = await lookupIntent(oldIntentId);
const [action, reason] = decide(order, lookupResult);
if (action !== "clear") continue;
console.warn(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would clear" : "clearing"}`);
if (!DRY_RUN) await clearStaleId(order, oldIntentId);
cleared++;
}
console.log(`Done. ${cleared} order(s) ${DRY_RUN ? "to clear" : "cleared"}.`);
}
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 history touched. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and the lookup result, then checks the action.
from clear_stale_intent_ids import decide, intent_id_of
def test_clear_when_id_not_found_and_order_finished():
order = {"status": "processing"}
assert decide(order, "not_found")[0] == "clear"
def test_skip_when_id_resolves():
order = {"status": "completed"}
assert decide(order, "resolved")[0] == "skip"
def test_skip_when_no_id_saved():
order = {"status": "processing"}
assert decide(order, "no_id")[0] == "skip"
def test_skip_when_order_not_finished_even_if_stale():
order = {"status": "pending"}
assert decide(order, "not_found")[0] == "skip"
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 "./clear-stale-intent-ids.js";
test("clear when id not found and order finished", () => {
assert.equal(decide({ status: "processing" }, "not_found")[0], "clear");
});
test("skip when id resolves", () => {
assert.equal(decide({ status: "completed" }, "resolved")[0], "skip");
});
test("skip when no id saved", () => {
assert.equal(decide({ status: "processing" }, "no_id")[0], "skip");
});
test("skip when order not finished even if stale", () => {
assert.equal(decide({ status: "pending" }, "not_found")[0], "skip");
});
test("intentIdOf falls back to transaction_id", () => {
assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});
Case studies
The store that inherited someone else's account
A merchant moved off an agency's shared Stripe account onto their own, fresh account, as part of taking full ownership of the store. New orders processed fine from day one. But a customer asked for a refund on an order from before the move, and the refund button failed silently with a No such payment_intent error in the logs.
Running the script in dry run first showed 340 finished orders with IDs from the old agency account. After confirming the list looked right, the team ran it for real, clearing the dead references and leaving a note with the original id on each order for their records.
The launch that carried over test orders
A developer built and tested checkout using Stripe test keys, generating a batch of realistic test orders to demo the store to the client. At launch, the store switched to live keys, but the client asked to keep the test orders visible in the order list as a record of what was tested.
Those orders' PaymentIntent IDs meant nothing to the new live key. The script found them all, confirmed each one resolved as not_found, and cleared the stale ids so nobody would later try to refund a test order against a live account and get confused by the error.
After this runs once, every finished order's Stripe reference either points at something the current account can actually see, or it has been cleared with a note explaining why. A refund, a renewal, or a sync job on any of these orders now either works cleanly or fails for a real reason, not because of a dead pointer from a gateway you left behind months ago.
FAQ
Why do refunds and renewals fail after we switch Stripe accounts or gateways?
Old orders still carry the PaymentIntent id from the previous account or gateway in their meta. That id does not exist under the new secret key, so Stripe returns a No such payment_intent error the moment anything tries to use it. Clearing the stale id off finished orders stops those calls from ever running.
Is it safe to clear the saved PaymentIntent id from an order?
Yes, when you only clear it on orders that have already finished, meaning Processing, Completed, Refunded, or On-hold, and only after confirming the id truly does not resolve in the current Stripe account. The order history and the amount already charged stay untouched, only the stale reference is removed.
How do I know a PaymentIntent id is actually stale and not just temporarily unavailable?
Stripe returns a specific resource_missing error code when an id does not exist in the account you are querying with. Any other error, like a network timeout or a rate limit, should not be treated as stale. The script only acts on the resource_missing case and leaves everything else alone.
Related field notes
Citations
On the problem:
- Stripe docs: object IDs are scoped to the account and mode that created them, an id from one account is not valid in another. docs.stripe.com/keys
- WooCommerce Stripe plugin docs: what happens to orders and gateway settings when you connect a different Stripe account. woocommerce.com/document/stripe
- WooCommerce support forum: reports of No such payment_intent errors following a Stripe account or gateway change. wordpress.org/support/plugin/woocommerce-gateway-stripe
On the solution:
- Stripe API: retrieve a PaymentIntent and the resource_missing error code returned when it does not exist. docs.stripe.com/api/payment_intents/retrieve
- Stripe docs: handling errors, including how to read error codes like resource_missing safely. docs.stripe.com/error-handling
- WooCommerce REST API: update an order's meta data and transaction_id, and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
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 clear up your gateway migration?
If this saved you a pile of failed refunds or a confusing support ticket, 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