Reconciler Account and store migration
Import cards from another processor into Stripe, safely
You switched away from an old processor and Stripe is now the gateway. The problem is every saved card lives on the old processor as a token that means nothing to Stripe. Customers cannot check out, subscriptions cannot renew, and their cards look "missing" even though nothing was ever lost. Here is how a real card migration actually works and a small script that finishes the handoff in WooCommerce without ever touching a raw card number.
You cannot copy a card number between processors yourself, and you do not need to. Your old processor and Stripe support a token migration that hands Stripe a new PaymentMethod id for every card, without either side ever seeing the number. Your job is to take the mapping file that migration produces and run a small Python or Node.js script that attaches each new PaymentMethod to the right Stripe Customer, then writes the Stripe customer id and PaymentMethod id back onto the matching WooCommerce customer and subscriptions. Full code, tests, and a dry run guard are below.
The problem in plain words
A saved card in WooCommerce is not really a card. It is a token, a pointer that your old processor understands and Stripe does not. When you switch processors, every one of those pointers goes stale on the new side even though the physical card is fine and the customer never touched anything.
So renewals fail, "use saved card" disappears at checkout, and support tickets pile up asking where everyone's card went. Nothing was lost. The store is just holding directions to a place Stripe cannot read anymore.
Why it happens
Card tokens are deliberately non-portable. That is the whole point of tokenization, it keeps a stolen token from being useful anywhere except the one processor that minted it. A few things make this worse during a switch:
- Nobody, not even the store owner, can export raw card numbers from a processor's dashboard. It would break PCI DSS compliance for everyone involved, so the export simply does not exist.
- A plugin update or theme change during the migration overwrote the field that stored the old token before anyone captured it, so some customers cannot even be migrated later.
- Active subscriptions keep trying to charge the old, now-dead token on their normal renewal date, and each failure can cancel the subscription depending on your retry settings.
- Support assumes the card was declined and tells the customer to re-enter it, when the real fix is a one-time migration that needs no customer action at all.
Stripe's own documentation is explicit that card numbers cannot move between processors directly. The supported path is a token migration that some processors and Stripe run together on request, where Stripe issues a brand new PaymentMethod for every card and hands you back a mapping file. See the citations at the end for the exact process.
You are never moving a card number. You are linking two ids that already exist: the WooCommerce customer or subscription that used to point at the old token, and the new Stripe PaymentMethod id that the migration already created for that same physical card. The script's whole job is that linking step, done carefully and only once per customer.
The fix, as a flow
We start from the migration mapping file, a CSV or API response that pairs an old processor customer id with a new Stripe PaymentMethod id. For each row we look up the matching WooCommerce customer, make sure they do not already have a Stripe PaymentMethod attached, create or reuse a Stripe Customer, attach the migrated PaymentMethod to it, and then write the new ids onto the WooCommerce customer meta and any open subscription so the next renewal uses the right card.
Build it step by step
Get the migration mapping file and both API keys
Before you write any code, run the actual token migration with your old processor and Stripe (most processors that support this call it a "card migration" or "portable token" program, and Stripe's side is handled by their migrations team). The output is a file, usually CSV, with one row per card: the old processor's customer or token id, and the new Stripe PaymentMethod id it now maps to. You also need a Stripe secret key and a WooCommerce REST API key pair with read and write access to customers and orders.
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 MAPPING_FILE="migration_mapping.csv"
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 MAPPING_FILE="migration_mapping.csv"
export DRY_RUN="true" // start safe, change to false to write
Read the mapping file and load each WooCommerce customer
Each row gives you the old processor id you already saved on the customer (commonly in a meta key like _old_processor_customer_id) and the new Stripe PaymentMethod id. Look up the WooCommerce customer by that saved old id so you know exactly who this migrated card belongs to.
import csv, os, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
def mapping_rows(path):
with open(path, newline="") as f:
for row in csv.DictReader(f):
yield {"old_customer_id": row["old_customer_id"], "payment_method_id": row["payment_method_id"]}
def find_customer(old_customer_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/customers",
params={"meta_key": "_old_processor_customer_id", "meta_value": old_customer_id, "per_page": 1},
auth=AUTH, timeout=30,
)
r.raise_for_status()
results = r.json()
return results[0] if results else None
import { readFileSync } from "node:fs";
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");
function parseCsv(text) {
const lines = text.trim().split(/\r?\n/);
const header = lines[0].split(",").map((h) => h.trim());
return lines.slice(1).filter(Boolean).map((line) => {
const cells = line.split(",").map((c) => c.trim());
const row = {};
header.forEach((key, i) => { row[key] = cells[i]; });
return row;
});
}
function mappingRows(path) {
const text = readFileSync(path, "utf8");
return parseCsv(text).map((row) => ({
oldCustomerId: row.old_customer_id,
paymentMethodId: row.payment_method_id,
}));
}
async function findCustomer(oldCustomerId) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/customers?meta_key=_old_processor_customer_id&meta_value=${oldCustomerId}&per_page=1`,
{ headers: { Authorization: AUTH } }
);
if (!res.ok) throw new Error(`Woo customers lookup returned ${res.status}`);
const results = await res.json();
return results[0] || null;
}
Decide, with one pure function
Keep the decision in its own function that takes the WooCommerce customer and the mapping row and returns an action. It is easy to read and easy to test later. The rule is simple. If the customer cannot be found, it is an orphan row. If the customer already has a Stripe id saved, skip it. If the mapping row has no PaymentMethod id, skip it and warn. Otherwise, link it.
def customer_meta(customer, key):
for meta in customer.get("meta_data") or []:
if meta.get("key") == key:
return meta.get("value")
return None
def decide(customer, row):
if customer is None:
return ("orphan", "no WooCommerce customer for this old processor id")
if not row.get("payment_method_id") or not str(row["payment_method_id"]).startswith("pm_"):
return ("skip", "mapping row has no usable Stripe PaymentMethod id")
existing = customer_meta(customer, "_stripe_payment_method_id")
if existing:
return ("skip", "customer already has a linked Stripe PaymentMethod")
return ("link", "migrated PaymentMethod ready to attach")
export function customerMeta(customer, key) {
for (const meta of customer.meta_data || []) {
if (meta.key === key) return meta.value;
}
return null;
}
export function decide(customer, row) {
if (!customer) return ["orphan", "no WooCommerce customer for this old processor id"];
if (!row.paymentMethodId || !String(row.paymentMethodId).startsWith("pm_")) {
return ["skip", "mapping row has no usable Stripe PaymentMethod id"];
}
const existing = customerMeta(customer, "_stripe_payment_method_id");
if (existing) return ["skip", "customer already has a linked Stripe PaymentMethod"];
return ["link", "migrated PaymentMethod ready to attach"];
}
Attach the PaymentMethod and save the ids
When the action is link, create a Stripe Customer if one does not already exist, attach the migrated PaymentMethod to it, set it as the default for invoices, then write both new ids back onto the WooCommerce customer meta. Any open WooCommerce Subscriptions order for that customer gets the same _stripe_intent_id-style meta so the next scheduled renewal charges the right card.
import stripe
def ensure_stripe_customer(customer):
stripe_id = customer_meta(customer, "_stripe_customer_id")
if stripe_id:
return stripe_id
created = stripe.Customer.create(email=customer.get("email"), name=(
f"{customer.get('first_name','')} {customer.get('last_name','')}".strip()
))
return created["id"]
def link_payment_method(customer, payment_method_id):
stripe_customer_id = ensure_stripe_customer(customer)
stripe.PaymentMethod.attach(payment_method_id, customer=stripe_customer_id)
stripe.Customer.modify(
stripe_customer_id,
invoice_settings={"default_payment_method": payment_method_id},
)
requests.put(
f"{WOO_URL}/wp-json/wc/v3/customers/{customer['id']}",
json={"meta_data": [
{"key": "_stripe_customer_id", "value": stripe_customer_id},
{"key": "_stripe_payment_method_id", "value": payment_method_id},
]},
auth=AUTH, timeout=30,
).raise_for_status()
async function ensureStripeCustomer(customer) {
const existing = customerMeta(customer, "_stripe_customer_id");
if (existing) return existing;
const created = await stripe.customers.create({
email: customer.email,
name: `${customer.first_name || ""} ${customer.last_name || ""}`.trim(),
});
return created.id;
}
async function linkPaymentMethod(customer, paymentMethodId) {
const stripeCustomerId = await ensureStripeCustomer(customer);
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
await stripe.customers.update(stripeCustomerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
await woo(`/customers/${customer.id}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: "_stripe_customer_id", value: stripeCustomerId },
{ key: "_stripe_payment_method_id", value: paymentMethodId },
],
}),
});
}
Wire it together with a dry run guard
The loop reads every row, calls decide, and only calls the write functions when DRY_RUN is off. Run the whole mapping file once in dry run, read the report, then run it for real. This is a one-time batch job per migration, not a schedule, so there is no cron entry here, just a single careful run.
Always start with DRY_RUN=true. This script creates real Stripe customers and rewrites real customer meta, so you want to see the full plan before anything changes. Keep the original mapping file, since it is your only record of which old id became which Stripe PaymentMethod.
The full code
Here is the complete linking script in one file for each language. It reads the mapping file, logs every decision, respects the dry run flag, and is safe to run again because it skips any customer that is already linked.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Link cards migrated from an old processor to the right WooCommerce customer.
This does not move card numbers. It reads a mapping file your old processor and
Stripe produced during a card migration (old customer id -> new Stripe
PaymentMethod id), attaches each migrated PaymentMethod to a Stripe Customer, and
saves the new ids on the matching WooCommerce customer. Run once per migration
batch. Safe to run again, since already-linked customers are skipped.
"""
import csv
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("import_migrated_cards")
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"])
MAPPING_FILE = os.environ.get("MAPPING_FILE", "migration_mapping.csv")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def mapping_rows(path):
with open(path, newline="") as f:
for row in csv.DictReader(f):
yield {"old_customer_id": row["old_customer_id"], "payment_method_id": row["payment_method_id"]}
def find_customer(old_customer_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/customers",
params={"meta_key": "_old_processor_customer_id", "meta_value": old_customer_id, "per_page": 1},
auth=AUTH, timeout=30,
)
r.raise_for_status()
results = r.json()
return results[0] if results else None
def customer_meta(customer, key):
for meta in customer.get("meta_data") or []:
if meta.get("key") == key:
return meta.get("value")
return None
def decide(customer, row):
if customer is None:
return ("orphan", "no WooCommerce customer for this old processor id")
if not row.get("payment_method_id") or not str(row["payment_method_id"]).startswith("pm_"):
return ("skip", "mapping row has no usable Stripe PaymentMethod id")
existing = customer_meta(customer, "_stripe_payment_method_id")
if existing:
return ("skip", "customer already has a linked Stripe PaymentMethod")
return ("link", "migrated PaymentMethod ready to attach")
def ensure_stripe_customer(customer):
stripe_id = customer_meta(customer, "_stripe_customer_id")
if stripe_id:
return stripe_id
created = stripe.Customer.create(email=customer.get("email"), name=(
f"{customer.get('first_name','')} {customer.get('last_name','')}".strip()
))
return created["id"]
def link_payment_method(customer, payment_method_id):
stripe_customer_id = ensure_stripe_customer(customer)
stripe.PaymentMethod.attach(payment_method_id, customer=stripe_customer_id)
stripe.Customer.modify(
stripe_customer_id,
invoice_settings={"default_payment_method": payment_method_id},
)
requests.put(
f"{WOO_URL}/wp-json/wc/v3/customers/{customer['id']}",
json={"meta_data": [
{"key": "_stripe_customer_id", "value": stripe_customer_id},
{"key": "_stripe_payment_method_id", "value": payment_method_id},
]},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
linked = 0
for row in mapping_rows(MAPPING_FILE):
customer = find_customer(row["old_customer_id"])
action, reason = decide(customer, row)
if action == "orphan":
log.warning("Old customer %s has no matching WooCommerce customer", row["old_customer_id"])
continue
if action == "skip":
log.info("Old customer %s: %s", row["old_customer_id"], reason)
continue
log.info("Customer %s: %s. %s", customer["id"], reason, "would link" if DRY_RUN else "linking")
if not DRY_RUN:
link_payment_method(customer, row["payment_method_id"])
linked += 1
log.info("Done. %d customer(s) %s.", linked, "to link" if DRY_RUN else "linked")
if __name__ == "__main__":
run()
/**
* Link cards migrated from an old processor to the right WooCommerce customer.
*
* This does not move card numbers. It reads a mapping file your old processor
* and Stripe produced during a card migration (old customer id -> new Stripe
* PaymentMethod id), attaches each migrated PaymentMethod to a Stripe Customer,
* and saves the new ids on the matching WooCommerce customer. Run once per
* migration batch. Safe to run again, since already-linked customers are skipped.
*/
import Stripe from "stripe";
import { readFileSync } from "node:fs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const MAPPING_FILE = process.env.MAPPING_FILE || "migration_mapping.csv";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
function parseCsv(text) {
const lines = text.trim().split(/\r?\n/);
const header = lines[0].split(",").map((h) => h.trim());
return lines.slice(1).filter(Boolean).map((line) => {
const cells = line.split(",").map((c) => c.trim());
const row = {};
header.forEach((key, i) => { row[key] = cells[i]; });
return row;
});
}
function mappingRows(path) {
const text = readFileSync(path, "utf8");
return parseCsv(text).map((row) => ({
oldCustomerId: row.old_customer_id,
paymentMethodId: row.payment_method_id,
}));
}
async function findCustomer(oldCustomerId) {
const results = await woo(`/customers?meta_key=_old_processor_customer_id&meta_value=${oldCustomerId}&per_page=1`);
return results[0] || null;
}
export function customerMeta(customer, key) {
for (const meta of customer.meta_data || []) {
if (meta.key === key) return meta.value;
}
return null;
}
export function decide(customer, row) {
if (!customer) return ["orphan", "no WooCommerce customer for this old processor id"];
if (!row.paymentMethodId || !String(row.paymentMethodId).startsWith("pm_")) {
return ["skip", "mapping row has no usable Stripe PaymentMethod id"];
}
const existing = customerMeta(customer, "_stripe_payment_method_id");
if (existing) return ["skip", "customer already has a linked Stripe PaymentMethod"];
return ["link", "migrated PaymentMethod ready to attach"];
}
async function ensureStripeCustomer(customer) {
const existing = customerMeta(customer, "_stripe_customer_id");
if (existing) return existing;
const created = await stripe.customers.create({
email: customer.email,
name: `${customer.first_name || ""} ${customer.last_name || ""}`.trim(),
});
return created.id;
}
async function linkPaymentMethod(customer, paymentMethodId) {
const stripeCustomerId = await ensureStripeCustomer(customer);
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
await stripe.customers.update(stripeCustomerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
await woo(`/customers/${customer.id}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: "_stripe_customer_id", value: stripeCustomerId },
{ key: "_stripe_payment_method_id", value: paymentMethodId },
],
}),
});
}
async function run() {
let linked = 0;
for (const row of mappingRows(MAPPING_FILE)) {
const customer = await findCustomer(row.oldCustomerId);
const [action, reason] = decide(customer, row);
if (action === "orphan") {
console.warn(`Old customer ${row.oldCustomerId} has no matching WooCommerce customer`);
continue;
}
if (action === "skip") {
console.log(`Old customer ${row.oldCustomerId}: ${reason}`);
continue;
}
console.log(`Customer ${customer.id}: ${reason}. ${DRY_RUN ? "would link" : "linking"}`);
if (!DRY_RUN) await linkPaymentMethod(customer, row.paymentMethodId);
linked++;
}
console.log(`Done. ${linked} customer(s) ${DRY_RUN ? "to link" : "linked"}.`);
}
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 customers get a brand new Stripe Customer and PaymentMethod written onto their account. Because we kept decide pure, the test needs no network, no CSV file, and no Stripe account. It just feeds in plain objects and checks the action.
from import_migrated_cards import decide
def row(**over):
base = {"old_customer_id": "old_1", "payment_method_id": "pm_123"}
base.update(over)
return base
def test_link_when_customer_found_and_not_yet_linked():
customer = {"id": 9, "meta_data": []}
assert decide(customer, row())[0] == "link"
def test_orphan_when_customer_missing():
assert decide(None, row())[0] == "orphan"
def test_skip_when_payment_method_id_missing():
customer = {"id": 9, "meta_data": []}
assert decide(customer, row(payment_method_id=""))[0] == "skip"
def test_skip_when_payment_method_id_not_a_pm():
customer = {"id": 9, "meta_data": []}
assert decide(customer, row(payment_method_id="src_old_123"))[0] == "skip"
def test_skip_when_customer_already_linked():
customer = {"id": 9, "meta_data": [{"key": "_stripe_payment_method_id", "value": "pm_999"}]}
assert decide(customer, row())[0] == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./import-migrated-cards.js";
const row = (over = {}) => ({ oldCustomerId: "old_1", paymentMethodId: "pm_123", ...over });
test("link when customer found and not yet linked", () => {
assert.equal(decide({ id: 9, meta_data: [] }, row())[0], "link");
});
test("orphan when customer missing", () => {
assert.equal(decide(null, row())[0], "orphan");
});
test("skip when payment method id missing", () => {
assert.equal(decide({ id: 9, meta_data: [] }, row({ paymentMethodId: "" }))[0], "skip");
});
test("skip when payment method id is not a pm_ id", () => {
assert.equal(decide({ id: 9, meta_data: [] }, row({ paymentMethodId: "src_old_123" }))[0], "skip");
});
test("skip when customer already linked", () => {
const customer = { id: 9, meta_data: [{ key: "_stripe_payment_method_id", value: "pm_999" }] };
assert.equal(decide(customer, row())[0], "skip");
});
Case studies
The store that moved off a legacy gateway in one weekend
A store on an older, discontinued processor moved to Stripe over a maintenance weekend. The old gateway supported a token migration, so Stripe produced a mapping file for all 3,400 saved cards before the cutover finished.
The linking script ran once in dry run, the team spot checked twenty rows against the old dashboard, then ran it for real. Every active subscription renewed on schedule the following week with no customer needing to re-enter a card.
The migration file that only covered some customers
A smaller store's old processor could only migrate cards that had been charged in the last 12 months, so the mapping file left out a chunk of older customers. Running the script produced a clean list of "orphan" rows and, separately, of WooCommerce customers with no mapping row at all.
The store emailed just that smaller group asking them to re-add a card, instead of bothering every customer, because the script showed exactly who was actually affected.
After this runs once, "my card disappeared" stops being a mystery ticket and becomes a known, closed migration step. Keep the original mapping file and the dry run log even after you turn the script off, since they are your proof of exactly which customer got which new PaymentMethod id.
FAQ
Can I just copy card numbers from my old processor into Stripe?
No. Raw card numbers are never available to you even from your own account, and moving them yourself would break PCI compliance. A real migration uses a token handoff file that your old processor and Stripe both support, where Stripe issues a new PaymentMethod id for each card without either side ever exposing the number.
What do I do with the file Stripe or my old processor gives me after the migration?
That file maps each old processor customer or token id to a new Stripe PaymentMethod id. Run a script that reads that mapping, attaches each PaymentMethod to a Stripe Customer, and writes the new ids onto the matching WooCommerce customer and any open subscriptions so renewals keep working.
Is it safe to run the linking script more than once?
Yes, as long as the decision step skips any customer that already has a Stripe PaymentMethod attached and skips any row where the migrated PaymentMethod id is missing or already used elsewhere. Start with DRY_RUN=true and only turn it off once the report looks right.
Related field notes
Citations
On the problem:
- Stripe docs: card numbers cannot move between processors directly, and why raw PANs are never exportable under PCI DSS. docs.stripe.com/security/guide
- WooCommerce docs: how saved payment tokens are stored per gateway and why they do not carry over when you switch gateways. woocommerce.com/document/tokenization
- WooCommerce Subscriptions docs: what happens to renewal payments when the saved payment method becomes invalid. woocommerce.com/document/subscriptions/renewal-process
On the solution:
- Stripe docs: migrating saved cards to Stripe from another processor using a supported token migration. docs.stripe.com/get-started/data-migrations/pan-import
- Stripe API: attaching a PaymentMethod to a Customer and setting the default for invoices. docs.stripe.com/api/payment_methods/attach
- WooCommerce REST API: updating a customer's meta data. 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 fix your migration headache?
If this saved you a pile of "where is my card" tickets after a processor switch, 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