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.

Python and Node.js Runs once per migration batch Safe by default (dry run)
A green and black computer motherboard
Photo by Toby56 on Unsplash
The short answer

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.

Card saved on old processor Store switches gateway to Stripe token unknown Stripe rejects the saved card Checkout fails Renewals stall
The card is fine. The token that points to it only means something on the processor that issued it.

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:

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.

The key insight

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.

Read mapping row old id to pm_ id Find Woo customer by saved old processor id Check for existing Stripe PaymentMethod Needs linking? yes no, skip Attach + save ids customer + subscription
The script never creates a card. It links an already-migrated PaymentMethod to the customer and subscription that need it, and only once.

Build it step by step

1

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.

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 MAPPING_FILE="migration_mapping.csv"
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 MAPPING_FILE="migration_mapping.csv"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

step2.py
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
step2.js
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;
}
3

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.

decide.py
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")
decide.js
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"];
}
4

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.

apply.py
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()
apply.js
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 },
      ],
    }),
  });
}
5

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.

Run it safe

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.

import_migrated_cards.py
"""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()
import-migrated-cards.js
/**
 * 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.

test_import_decide.py
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-migrated-cards.test.js
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

Full migration

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.

Partial mapping

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.

What good looks like

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:

  1. Stripe docs: card numbers cannot move between processors directly, and why raw PANs are never exportable under PCI DSS. docs.stripe.com/security/guide
  2. 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
  3. WooCommerce Subscriptions docs: what happens to renewal payments when the saved payment method becomes invalid. woocommerce.com/document/subscriptions/renewal-process

On the solution:

  1. Stripe docs: migrating saved cards to Stripe from another processor using a supported token migration. docs.stripe.com/get-started/data-migrations/pan-import
  2. Stripe API: attaching a PaymentMethod to a Customer and setting the default for invoices. docs.stripe.com/api/payment_methods/attach
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes