Repair Account and store migration

Remove test mode Stripe IDs from live WooCommerce orders

Somewhere in your live store, an order is holding a Stripe PaymentIntent id that only exists in test mode. It happened during a migration, a staging push, or a developer checking out with the wrong keys turned on. The order looks fine in WooCommerce, right up until something tries to use that id against your live Stripe account and gets a flat "no such payment_intent" back. Here is why it happens and a small script that finds every one of these orders and clears the bad reference safely.

Python and Node.js Runs on a schedule or once Safe by default (dry run)
A pile of assorted color papers
Photo by Omid Kashmari on Unsplash
The short answer

A test mode PaymentIntent id and a live mode PaymentIntent id look identical, both start with pi_, so WooCommerce cannot tell them apart on sight. The only way to know is to ask your live Stripe account to retrieve the id. If Stripe replies with resource_missing, the id belongs to test mode and does not belong on a live order. Run a small Python or Node.js script on a schedule that reads _stripe_intent_id (or transaction_id) off each order, checks it against the live Stripe account, and clears the ones that do not exist, leaving a note behind. Full code, tests, and a dry run guard are below.

The problem in plain words

Stripe keeps two completely separate worlds behind one dashboard: test mode and live mode. Every object, customers, PaymentIntents, charges, exists in only one of the two. A test mode secret key can only see test mode objects. A live secret key can only see live mode objects. Neither key can see across the line.

The trouble is that the ids themselves give no hint about which world they belong to. A test PaymentIntent id and a live PaymentIntent id are formatted exactly the same way. So when a test id gets saved onto a live order, everything looks normal in the WooCommerce admin. The order shows Processing, the id field is filled in, nothing looks broken, until a refund, a dispute sync, or a reconciler tries to look that id up against the live Stripe account and gets told it does not exist.

Live order stores pi_ id But the id was made in Stripe test mode wrong Stripe world Live account looks it up resource_missing Refund fails
The order looks paid and complete. The id it points to simply does not exist in the live Stripe account it is checked against.

Why it happens

Stripe's own docs are explicit that test mode and live mode data never cross over, on purpose, to keep real money and fake money completely apart. That same wall is what makes a leftover test id so easy to miss until something breaks. A few common ways it happens:

The WooCommerce Stripe plugin does show a banner when test keys are active, but it is easy to miss during a busy migration, and nothing in WooCommerce itself checks that a saved id actually resolves against the mode you are currently live on. See the citations at the end for the exact references.

The key insight

You cannot tell test and live PaymentIntent ids apart by reading them. The only trustworthy check is to ask the live Stripe account to retrieve the id. If it comes back resource_missing, that id is not from this account's live mode, full stop. A small script that walks recent orders and asks this one question finds every affected order without guessing.

The fix, as a flow

We do not touch checkout or the gateway settings. We add a script that walks recent orders, reads the saved PaymentIntent id off each one, and asks the live Stripe account to retrieve it. If Stripe says the id does not exist, we clear the bad reference from the order, add a note explaining why, and optionally move the order to on-hold so a human can reconcile it with the real payment. We never invent a replacement id.

Scheduled job or a one-off run List recent orders read saved PaymentIntent id Retrieve on live Stripe account Found in live mode? yes, leave it alone no, missing Clear the id add a note for review
The script only clears an id after the live Stripe account itself confirms the id does not exist there. Orders with a genuine live id are never touched.

Build it step by step

1

Get access to both systems

You need your live Stripe secret key (starts with sk_live_, never sk_test_ for this script) 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.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export REVIEW_HOLD="false"   # true also moves cleared orders to on-hold
export DRY_RUN="true"        # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="30"
export REVIEW_HOLD="false"   // true also moves cleared orders to on-hold
export DRY_RUN="true"        // start safe, change to false to write
2

Read the saved Stripe id off each order

The WooCommerce Stripe plugin saves the PaymentIntent id in order meta under _stripe_intent_id. Older orders sometimes only have it in transaction_id. Check the meta key first and fall back to transaction_id when it looks like a PaymentIntent id (it starts with pi_, not a charge id starting with ch_).

step2.py
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
step2.js
export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}
3

Ask the live account whether the id exists

Retrieve the PaymentIntent using your live secret key. Stripe raises an InvalidRequestError with code resource_missing when an id from another mode (or another account entirely) is looked up. That single response is the whole test, since Stripe never leaks whether an id "exists but in the wrong mode" versus "never existed", it just says it cannot find it.

step3.py
import stripe

def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError as e:
        if getattr(e, "code", None) == "resource_missing":
            return None
        raise
step3.js
async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch (err) {
    if (err.code === "resource_missing") return null;
    throw err;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order and the result of the lookup and returns an action. The rule is simple. No saved id at all is not our problem here, skip it. A missing PaymentIntent on an order that never got past a cart or a failed attempt is not worth touching either, only orders WooCommerce currently trusts as paid matter. When the id is missing on a paid-looking order, that is a test id leak, clear it.

decide.py
PAID_STATUSES = {"processing", "completed", "on-hold"}

def decide(order, intent_id, intent):
    if not intent_id:
        return ("skip", "no Stripe id saved on this order")
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order is not in a state that relies on this id")
    if intent is not None:
        return ("ok", "id resolves on the live Stripe account")
    return ("clear", "id does not exist on the live Stripe account, likely test mode")
decide.js
const PAID_STATUSES = new Set(["processing", "completed", "on-hold"]);

export function decide(order, intentId, intent) {
  if (!intentId) return ["skip", "no Stripe id saved on this order"];
  if (!PAID_STATUSES.has(order.status)) {
    return ["skip", "order is not in a state that relies on this id"];
  }
  if (intent) return ["ok", "id resolves on the live Stripe account"];
  return ["clear", "id does not exist on the live Stripe account, likely test mode"];
}
5

Clear the bad reference, never guess a replacement

When the action is clear, wipe the _stripe_intent_id meta and the transaction_id field, add an order note explaining exactly what was found and why, and, only if you opt in with REVIEW_HOLD, move the order to on-hold so a human confirms whether the customer actually paid before it ships or renews again.

apply.py
def clear_test_id(order_id, 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 Stripe id {intent_id}: it does not exist on the live "
                      f"Stripe account and is likely a test mode id. Please confirm this "
                      f"order was actually paid before shipping or renewing it."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if REVIEW_HOLD:
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
            json={"status": "on-hold"}, auth=AUTH, timeout=30,
        ).raise_for_status()
apply.js
async function clearTestId(orderId, intentId) {
  await woo(`/orders/${orderId}`, {
    method: "PUT",
    body: JSON.stringify({
      transaction_id: "",
      meta_data: [{ key: "_stripe_intent_id", value: "" }],
    }),
  });
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Cleared Stripe id ${intentId}: it does not exist on the live Stripe account ` +
            `and is likely a test mode id. Please confirm this order was actually paid ` +
            `before shipping or renewing it.`,
    }),
  });
  if (REVIEW_HOLD) {
    await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
  }
}
6

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, confirm every flagged id truly does not exist on the live account, then switch it off. Run it once right after a migration, and again on a light schedule for a few weeks afterward in case any late orders still carry the leak.

Run it safe

Always start with DRY_RUN=true. Clearing an id is a one-way action from the script's point of view, so you want to see the exact list before it writes anything. Double check the flagged orders by hand the first time, then trust the automation.

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 ever clears an id after the live Stripe account itself confirms the id is missing.

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

remove_test_ids.py
"""Find and clear test mode Stripe PaymentIntent ids saved on live WooCommerce orders.

A test id and a live id look identical, both start with pi_, so the only reliable
check is asking the live Stripe account to retrieve it. Run once after a migration,
then on a light schedule for a few weeks. Read only 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("remove_test_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", "30"))
REVIEW_HOLD = os.environ.get("REVIEW_HOLD", "false").lower() == "true"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed", "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 get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError as e:
        if getattr(e, "code", None) == "resource_missing":
            return None
        raise


def decide(order, intent_id, intent):
    if not intent_id:
        return ("skip", "no Stripe id saved on this order")
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order is not in a state that relies on this id")
    if intent is not None:
        return ("ok", "id resolves on the live Stripe account")
    return ("clear", "id does not exist on the live Stripe account, likely test mode")


def candidate_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={"status": "processing,completed,on-hold", "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_test_id(order_id, 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 Stripe id {intent_id}: it does not exist on the live "
                      f"Stripe account and is likely a test mode id. Please confirm this "
                      f"order was actually paid before shipping or renewing it."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if REVIEW_HOLD:
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
            json={"status": "on-hold"}, auth=AUTH, timeout=30,
        ).raise_for_status()


def run():
    cleared = 0
    for order in candidate_orders():
        intent_id = intent_id_of(order)
        intent = get_intent(intent_id)
        action, reason = decide(order, intent_id, intent)
        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_test_id(order["id"], intent_id)
        cleared += 1
    log.info("Done. %d order(s) %s.", cleared, "to clear" if DRY_RUN else "cleared")


if __name__ == "__main__":
    run()
remove-test-ids.js
/**
 * Find and clear test mode Stripe PaymentIntent ids saved on live WooCommerce orders.
 *
 * A test id and a live id look identical, both start with pi_, so the only reliable
 * check is asking the live Stripe account to retrieve it. Run once after a migration,
 * then on a light schedule for a few weeks. Read only by default.
 *
 * Guide: https://www.allanninal.dev/woocommerce/remove-test-ids-from-live/
 */
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 REVIEW_HOLD = (process.env.REVIEW_HOLD || "false").toLowerCase() === "true";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed", "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 decide(order, intentId, intent) {
  if (!intentId) return ["skip", "no Stripe id saved on this order"];
  if (!PAID_STATUSES.has(order.status)) {
    return ["skip", "order is not in a state that relies on this id"];
  }
  if (intent) return ["ok", "id resolves on the live Stripe account"];
  return ["clear", "id does not exist on the live Stripe account, likely test mode"];
}

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 (err) {
    if (err.code === "resource_missing") return null;
    throw err;
  }
}

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

async function clearTestId(orderId, intentId) {
  await woo(`/orders/${orderId}`, {
    method: "PUT",
    body: JSON.stringify({
      transaction_id: "",
      meta_data: [{ key: "_stripe_intent_id", value: "" }],
    }),
  });
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Cleared Stripe id ${intentId}: it does not exist on the live Stripe account ` +
            `and is likely a test mode id. Please confirm this order was actually paid ` +
            `before shipping or renewing it.`,
    }),
  });
  if (REVIEW_HOLD) {
    await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
  }
}

export async function run() {
  let cleared = 0;
  for await (const order of candidateOrders()) {
    const intentId = intentIdOf(order);
    const intent = await getIntent(intentId);
    const [action, reason] = decide(order, intentId, intent);
    if (action !== "clear") continue;
    console.warn(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would clear" : "clearing"}`);
    if (!DRY_RUN) await clearTestId(order.id, intentId);
    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 payment reference wiped. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_testid_decide.py
from remove_test_ids import decide, intent_id_of


def test_clear_when_id_missing_on_live():
    order = {"status": "processing"}
    assert decide(order, "pi_test_123", None)[0] == "clear"


def test_ok_when_id_resolves():
    order = {"status": "processing"}
    assert decide(order, "pi_live_123", {"id": "pi_live_123"})[0] == "ok"


def test_skip_when_no_id_saved():
    order = {"status": "processing"}
    assert decide(order, None, None)[0] == "skip"


def test_skip_when_order_not_in_paid_state():
    order = {"status": "pending"}
    assert decide(order, "pi_test_123", None)[0] == "skip"


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"


def test_intent_id_falls_back_to_transaction_id():
    order = {"meta_data": [], "transaction_id": "pi_456"}
    assert intent_id_of(order) == "pi_456"


def test_intent_id_none_when_transaction_is_a_charge():
    order = {"meta_data": [], "transaction_id": "ch_789"}
    assert intent_id_of(order) is None
remove-test-ids.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./remove-test-ids.js";

test("clear when id missing on live", () => {
  assert.equal(decide({ status: "processing" }, "pi_test_123", null)[0], "clear");
});

test("ok when id resolves", () => {
  assert.equal(decide({ status: "processing" }, "pi_live_123", { id: "pi_live_123" })[0], "ok");
});

test("skip when no id saved", () => {
  assert.equal(decide({ status: "processing" }, null, null)[0], "skip");
});

test("skip when order not in paid state", () => {
  assert.equal(decide({ status: "pending" }, "pi_test_123", null)[0], "skip");
});

test("intentIdOf from meta", () => {
  assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});

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

test("intentIdOf null when transaction is a charge", () => {
  assert.equal(intentIdOf({ meta_data: [], transaction_id: "ch_789" }), null);
});

Case studies

Launch day

The staging site that went live with test keys

An agency finished a rebuild on staging, pointed the domain at the new site, and forgot the Stripe gateway was still set to test keys. For about ninety minutes, real customers checked out and their orders looked normal, but every PaymentIntent id saved was a test mode id.

Once the keys were fixed, the script ran in dry run mode and listed the eleven affected orders. The team called each customer to confirm their card, and the script cleared the bad ids with a note explaining exactly what happened.

Data migration

The import that carried test orders into the live database

A store moved to a new host and a database import tool copied every order row, including a batch of internal test orders that had been placed with test keys months earlier. Those orders sat in Processing with test ids nobody noticed at first.

Running the script with a ninety day lookback surfaced the whole batch in one pass. None of them turned out to be real customers, so the team cleared them and cancelled the orders by hand.

What good looks like

After a migration or a key mix-up, run this once with a wide lookback window and a human review of the flagged list. Then let it run lightly for a few weeks in case any late orders still carry the leak. A clean live store never has an order pointing at an id its own Stripe account cannot find.

FAQ

How do test mode Stripe IDs end up on live orders?

It usually happens during a migration: a staging copy goes live without swapping the Stripe keys, a developer tests checkout with test keys turned on in production, or an import script carries over old order meta from a test account. The order looks paid, but the PaymentIntent id it points to only exists in Stripe test mode.

How can I tell if a Stripe ID is test mode or live mode just by looking at it?

You cannot. Test mode and live mode PaymentIntent ids use the exact same pi_ prefix and format. The only reliable way to tell them apart is to ask the live Stripe account to retrieve the id and see whether it says the object exists.

Is it safe to delete the bad ID with a script?

Yes, when the script confirms the live Stripe account returns a resource_missing error for that exact id, and it only clears the reference and adds a note rather than guessing a replacement. Start in dry run mode to review the list before it writes.

Related field notes

Citations

On the problem:

  1. Stripe docs: test mode and live mode are separate, keys and data never cross between them. docs.stripe.com/keys
  2. Stripe docs: differences between test mode and live mode, including how objects do not carry over. docs.stripe.com/test-mode
  3. WooCommerce Stripe plugin docs: switching between test and live keys and the warning banner. woocommerce.com/document/stripe

On the solution:

  1. Stripe API: retrieve a PaymentIntent and the resource_missing error code. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe docs: error types and codes reference, including resource_missing. docs.stripe.com/error-codes
  3. 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.

Contact me on LinkedIn

Did this fix your leftover test ids?

If this saved you from a failed refund 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

Back to all WooCommerce and Stripe field notes