Repair Sources to PaymentMethods and SCA

Legacy to new checkout cards

A store upgrades its checkout to the new, SCA-ready flow and support tickets start rolling in from returning customers whose saved card "just stopped working." Nothing is wrong with their card. The token WooCommerce saved for them under the old checkout is not something the new checkout can charge. Here is why that happens and a small script that finds every card like this and clears it, so the shopper is asked to re-enter their card once instead of getting a silent decline forever.

Python and Node.js Runs once after a checkout upgrade, or on a schedule Safe by default (dry run)
Online checkout screen with payment details and shopping cart.
Photo by Ze Vieira on Unsplash
The short answer

Cards saved through the old checkout were stored as Stripe Source or Card tokens sitting on the order, not as a PaymentMethod attached to a Stripe Customer. The new checkout only reuses attached PaymentMethods, so it refuses the old token. You cannot upgrade the token in place, since Stripe cannot mint a fresh attached PaymentMethod from a card number you never stored. Run a small Python or Node.js script that reads every saved WooCommerce payment token, checks it against Stripe, and removes any token the new checkout cannot reuse, so the shopper gets a clean prompt to re-enter their card instead of a repeated decline. Full code, tests, and a dry run guard are below.

The problem in plain words

The old WooCommerce Stripe checkout saved a card by keeping a Source id or a Card id, usually something like src_... or card_..., tucked into the order or the customer record. That was enough at the time, because the old checkout charged those tokens directly and nobody was asking for Strong Customer Authentication (SCA).

The new checkout is built around the Payment Element and Strong Customer Authentication. To charge a saved card safely off session, Stripe wants a PaymentMethod object that is explicitly attached to a Stripe Customer. A bare Source or Card token is not that. When the new checkout tries to reuse one of these old tokens, Stripe answers with an error such as "PaymentMethod was previously used without being attached to a Customer & Setup Intent," and the purchase fails before the shopper even sees a card form.

Returning buyer picks saved card Old token on file src_... or card_... not attached Stripe rejects it SCA-ready checkout Order fails no charge
The card is fine. The token WooCommerce saved for it is not something the new, SCA-ready checkout will trust for an off-session charge.

Why it happens

This is a normal side effect of upgrading a checkout, not a bug in either system. A few things line up to cause it:

Stripe's own migration guidance is explicit that Sources for card payments are on a deprecation path and that reusable, SCA-compliant payments need a PaymentMethod attached to a Customer. See the citations at the end for the exact documentation.

The key insight

You cannot repair an old token into a new one. Stripe has no way to attach a Customer to a card number it was never given directly, only a token that represents it. The only reliable fix is to detect which saved tokens the new checkout will reject and clear them, so the shopper is prompted once to enter their card again, this time saved the new, correct way.

The fix, as a flow

We do not touch the checkout itself. We add a script that walks every WooCommerce customer, reads their saved payment tokens, and checks each one against Stripe. A legacy Source or Card id is always dropped. A PaymentMethod is only dropped if Stripe no longer has it, or it exists but is not attached to any Stripe Customer. A healthy, attached PaymentMethod is left alone.

Run once after the upgrade List customers and saved tokens Look up on Stripe PaymentMethod or Source Attached and still valid? yes, keep it no Drop the token shopper re-enters card
Healthy, attached PaymentMethods are left alone. Everything the new checkout would reject is cleared once, up front, instead of failing at checkout later.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to customers. 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 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 DRY_RUN="true"   // start safe, change to false to write
2

List every customer and their saved tokens

Page through WooCommerce customers, then ask the REST API for each customer's saved payment tokens. Each token carries the gateway id Stripe gave it, which is what we check next.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])

def customers_with_tokens():
    page = 1
    while True:
        r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers",
                          params={"per_page": 50, "page": page}, auth=AUTH, timeout=30)
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for customer in batch:
            yield customer
        page += 1

def get_tokens(customer_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}/payment_tokens",
                      auth=AUTH, timeout=30)
    if r.status_code == 404:
        return []
    r.raise_for_status()
    return r.json()
step2.js
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");

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.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* customersWithTokens() {
  let page = 1;
  while (true) {
    const batch = await woo(`/customers?per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const customer of batch) yield customer;
    page++;
  }
}

async function getTokens(customerId) {
  const tokens = await woo(`/customers/${customerId}/payment_tokens`);
  return tokens || [];
}
3

Look each token up on Stripe

The gateway id tells you which Stripe endpoint to call. A pm_... id is a PaymentMethod, so retrieve it that way. A src_... or card_... id is a legacy Source, so retrieve it as one. A token Stripe no longer recognizes comes back as an error, which we treat as "gone."

step3.py
import stripe

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
PAYMENT_METHOD_PREFIX = "pm_"

def is_payment_method_shaped(gateway_id):
    return bool(gateway_id) and gateway_id.startswith(PAYMENT_METHOD_PREFIX)

def get_stripe_object(gateway_id):
    if not gateway_id:
        return None
    try:
        if is_payment_method_shaped(gateway_id):
            return stripe.PaymentMethod.retrieve(gateway_id)
        return stripe.Source.retrieve(gateway_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const PAYMENT_METHOD_PREFIX = "pm_";

function isPaymentMethodShaped(gatewayId) {
  return Boolean(gatewayId) && gatewayId.startsWith(PAYMENT_METHOD_PREFIX);
}

async function getStripeObject(gatewayId) {
  if (!gatewayId) return null;
  try {
    if (isPaymentMethodShaped(gatewayId)) return await stripe.paymentMethods.retrieve(gatewayId);
    return await stripe.sources.retrieve(gatewayId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the WooCommerce token and the Stripe object and returns an action. This is the part worth testing carefully, since it decides whether a real customer's saved card gets removed. A legacy Source or Card id is always a drop. A PaymentMethod is a keep only when Stripe still has it and it is attached to a customer.

decide.py
LEGACY_TOKEN_PREFIXES = ("src_", "card_")

def gateway_id_of(token):
    return (token.get("token") or "").strip() or None

def is_legacy_shaped(gateway_id):
    return bool(gateway_id) and gateway_id.startswith(LEGACY_TOKEN_PREFIXES)

def decide(token, stripe_object):
    gateway_id = gateway_id_of(token)
    if not gateway_id:
        return ("skip", "token has no gateway id")

    if is_payment_method_shaped(gateway_id):
        if stripe_object is None:
            return ("drop", "PaymentMethod no longer exists on Stripe")
        if not stripe_object.get("customer"):
            return ("drop", "PaymentMethod exists but is not attached to a Stripe Customer")
        return ("keep", "attached PaymentMethod, safe for the new checkout")

    if is_legacy_shaped(gateway_id):
        if stripe_object is None:
            return ("drop", "legacy token no longer exists on Stripe")
        if stripe_object.get("object") == "source" and stripe_object.get("status") != "chargeable":
            return ("drop", "legacy Source is no longer chargeable")
        return ("drop", "legacy Source or Card token, the new checkout cannot reuse it")

    return ("skip", "not a recognized Stripe token shape")
decide.js
const LEGACY_TOKEN_PREFIXES = ["src_", "card_"];

export function gatewayIdOf(token) {
  const id = (token.token || "").trim();
  return id || null;
}

export function isLegacyShaped(gatewayId) {
  return Boolean(gatewayId) && LEGACY_TOKEN_PREFIXES.some((p) => gatewayId.startsWith(p));
}

export function decide(token, stripeObject) {
  const gatewayId = gatewayIdOf(token);
  if (!gatewayId) return ["skip", "token has no gateway id"];

  if (isPaymentMethodShaped(gatewayId)) {
    if (!stripeObject) return ["drop", "PaymentMethod no longer exists on Stripe"];
    if (!stripeObject.customer) return ["drop", "PaymentMethod exists but is not attached to a Stripe Customer"];
    return ["keep", "attached PaymentMethod, safe for the new checkout"];
  }

  if (isLegacyShaped(gatewayId)) {
    if (!stripeObject) return ["drop", "legacy token no longer exists on Stripe"];
    if (stripeObject.object === "source" && stripeObject.status !== "chargeable") {
      return ["drop", "legacy Source is no longer chargeable"];
    }
    return ["drop", "legacy Source or Card token, the new checkout cannot reuse it"];
  }

  return ["skip", "not a recognized Stripe token shape"];
}
5

Drop the token and leave a note

When the action is drop, delete the WooCommerce payment token through the REST API and add a customer note explaining why, so a support agent looking at the account later understands what happened and can tell the shopper it is expected.

apply.py
def drop_token(customer_id, token_id, reason):
    requests.delete(
        f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}/payment_tokens/{token_id}",
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}/notes",
        json={"note": f"Removed a saved card that the new checkout could not reuse: {reason}. "
                      f"The shopper will be asked to re-enter their card on the next purchase."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function dropToken(customerId, tokenId, reason) {
  await woo(`/customers/${customerId}/payment_tokens/${tokenId}`, { method: "DELETE" });
  await woo(`/customers/${customerId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Removed a saved card that the new checkout could not reuse: ${reason}. ` +
            `The shopper will be asked to re-enter their card on the next purchase.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. Run it once with DRY_RUN on right after the checkout upgrade, read the list, confirm it looks right, then turn it off to let it write. It is also safe to run on a schedule afterward to catch anything new.

Run it safe

Always start with DRY_RUN=true. This script deletes saved cards from real customer accounts, so you want to see its plan before it acts. Once the report looks right, turn it off.

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 removes a token it has confirmed the new checkout cannot reuse.

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

repair_legacy_tokens.py
"""Find WooCommerce saved cards that the new checkout cannot charge, and clear them
so the shopper is prompted to re-enter their card instead of hitting a silent decline.
Safe by default (DRY_RUN=true). Run once after a checkout migration, or on a schedule.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_legacy_tokens")

stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"), os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

LEGACY_TOKEN_PREFIXES = ("src_", "card_")
PAYMENT_METHOD_PREFIX = "pm_"


def gateway_id_of(token):
    """The raw Stripe id WooCommerce stored for this saved payment token."""
    return (token.get("token") or "").strip() or None


def is_legacy_shaped(gateway_id):
    """True when the id is from the old Sources/Cards world, not a PaymentMethod."""
    return bool(gateway_id) and gateway_id.startswith(LEGACY_TOKEN_PREFIXES)


def is_payment_method_shaped(gateway_id):
    return bool(gateway_id) and gateway_id.startswith(PAYMENT_METHOD_PREFIX)


def decide(token, stripe_object):
    """Pure decision: what should we do with one saved WooCommerce payment token.

    token: dict with at least "token" (the Stripe id WooCommerce saved) and
           "is_default" (bool). Shape matches customers/{id}/payment_tokens.
    stripe_object: the retrieved Stripe object for that id (a PaymentMethod dict,
                   a legacy Source dict), or None when Stripe has no record of it
                   or the id was never even a Stripe id.

    Returns (action, reason):
      "keep"  - a real PaymentMethod, attached to a customer, still usable
      "drop"  - the new checkout cannot charge this safely, remove the token
      "skip"  - nothing we recognize as a saved card token, leave it alone
    """
    gateway_id = gateway_id_of(token)
    if not gateway_id:
        return ("skip", "token has no gateway id")

    if is_payment_method_shaped(gateway_id):
        if stripe_object is None:
            return ("drop", "PaymentMethod no longer exists on Stripe")
        if not stripe_object.get("customer"):
            return ("drop", "PaymentMethod exists but is not attached to a Stripe Customer")
        return ("keep", "attached PaymentMethod, safe for the new checkout")

    if is_legacy_shaped(gateway_id):
        if stripe_object is None:
            return ("drop", "legacy token no longer exists on Stripe")
        if stripe_object.get("object") == "source" and stripe_object.get("status") != "chargeable":
            return ("drop", "legacy Source is no longer chargeable")
        return ("drop", "legacy Source or Card token, the new checkout cannot reuse it")

    return ("skip", "not a recognized Stripe token shape")


def get_stripe_object(gateway_id):
    if not gateway_id:
        return None
    try:
        if is_payment_method_shaped(gateway_id):
            return stripe.PaymentMethod.retrieve(gateway_id)
        return stripe.Source.retrieve(gateway_id)
    except stripe.error.InvalidRequestError:
        return None


def customers_with_tokens():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/customers",
            params={"per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for customer in batch:
            yield customer
        page += 1


def get_tokens(customer_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}/payment_tokens",
        auth=AUTH, timeout=30,
    )
    if r.status_code == 404:
        return []
    r.raise_for_status()
    return r.json()


def drop_token(customer_id, token_id, reason):
    requests.delete(
        f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}/payment_tokens/{token_id}",
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/customers/{customer_id}/notes",
        json={"note": f"Removed a saved card that the new checkout could not reuse: {reason}. "
                      f"The shopper will be asked to re-enter their card on the next purchase."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    dropped = 0
    checked = 0
    for customer in customers_with_tokens():
        for token in get_tokens(customer["id"]):
            checked += 1
            gateway_id = gateway_id_of(token)
            stripe_object = get_stripe_object(gateway_id)
            action, reason = decide(token, stripe_object)
            if action != "drop":
                continue
            log.info(
                "Customer %s token %s: %s. %s",
                customer["id"], token.get("id"), reason,
                "would drop" if DRY_RUN else "dropping",
            )
            if not DRY_RUN:
                drop_token(customer["id"], token["id"], reason)
            dropped += 1
    log.info("Done. Checked %d token(s). %d %s.", checked, dropped, "to drop" if DRY_RUN else "dropped")


if __name__ == "__main__":
    run()
repair-legacy-tokens.js
/**
 * Find WooCommerce saved cards that the new checkout cannot charge, and clear them
 * so the shopper is prompted to re-enter their card instead of hitting a silent decline.
 * Safe by default (DRY_RUN=true). Run once after a checkout migration, or on a schedule.
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const LEGACY_TOKEN_PREFIXES = ["src_", "card_"];
const PAYMENT_METHOD_PREFIX = "pm_";

export function gatewayIdOf(token) {
  const id = (token.token || "").trim();
  return id || null;
}

export function isLegacyShaped(gatewayId) {
  return Boolean(gatewayId) && LEGACY_TOKEN_PREFIXES.some((p) => gatewayId.startsWith(p));
}

export function isPaymentMethodShaped(gatewayId) {
  return Boolean(gatewayId) && gatewayId.startsWith(PAYMENT_METHOD_PREFIX);
}

export function decide(token, stripeObject) {
  const gatewayId = gatewayIdOf(token);
  if (!gatewayId) return ["skip", "token has no gateway id"];

  if (isPaymentMethodShaped(gatewayId)) {
    if (!stripeObject) return ["drop", "PaymentMethod no longer exists on Stripe"];
    if (!stripeObject.customer) return ["drop", "PaymentMethod exists but is not attached to a Stripe Customer"];
    return ["keep", "attached PaymentMethod, safe for the new checkout"];
  }

  if (isLegacyShaped(gatewayId)) {
    if (!stripeObject) return ["drop", "legacy token no longer exists on Stripe"];
    if (stripeObject.object === "source" && stripeObject.status !== "chargeable") {
      return ["drop", "legacy Source is no longer chargeable"];
    }
    return ["drop", "legacy Source or Card token, the new checkout cannot reuse it"];
  }

  return ["skip", "not a recognized Stripe token shape"];
}

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.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function getStripeObject(gatewayId) {
  if (!gatewayId) return null;
  try {
    if (isPaymentMethodShaped(gatewayId)) return await stripe.paymentMethods.retrieve(gatewayId);
    return await stripe.sources.retrieve(gatewayId);
  } catch {
    return null;
  }
}

async function* customersWithTokens() {
  let page = 1;
  while (true) {
    const batch = await woo(`/customers?per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const customer of batch) yield customer;
    page++;
  }
}

async function getTokens(customerId) {
  const tokens = await woo(`/customers/${customerId}/payment_tokens`);
  return tokens || [];
}

async function dropToken(customerId, tokenId, reason) {
  await woo(`/customers/${customerId}/payment_tokens/${tokenId}`, { method: "DELETE" });
  await woo(`/customers/${customerId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Removed a saved card that the new checkout could not reuse: ${reason}. ` +
            `The shopper will be asked to re-enter their card on the next purchase.`,
    }),
  });
}

export async function run() {
  let dropped = 0;
  let checked = 0;
  for await (const customer of customersWithTokens()) {
    for (const token of await getTokens(customer.id)) {
      checked++;
      const gatewayId = gatewayIdOf(token);
      const stripeObject = await getStripeObject(gatewayId);
      const [action, reason] = decide(token, stripeObject);
      if (action !== "drop") continue;
      console.log(
        `Customer ${customer.id} token ${token.id}: ${reason}. ${DRY_RUN ? "would drop" : "dropping"}`
      );
      if (!DRY_RUN) await dropToken(customer.id, token.id, reason);
      dropped++;
    }
  }
  console.log(`Done. Checked ${checked} token(s). ${dropped} ${DRY_RUN ? "to drop" : "dropped"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((e) => { console.error(e); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a real shopper's saved card gets removed. Because decide is pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_legacy_decide.py
from repair_legacy_tokens import decide, gateway_id_of, is_legacy_shaped, is_payment_method_shaped


def token(**over):
    base = {"id": 1, "token": "pm_123", "is_default": False}
    base.update(over)
    return base


def payment_method(**over):
    base = {"object": "payment_method", "id": "pm_123", "customer": "cus_1"}
    base.update(over)
    return base


def source(**over):
    base = {"object": "source", "id": "src_123", "status": "chargeable"}
    base.update(over)
    return base


def test_keep_attached_payment_method():
    assert decide(token(token="pm_123"), payment_method())[0] == "keep"


def test_drop_payment_method_missing_on_stripe():
    assert decide(token(token="pm_123"), None)[0] == "drop"


def test_drop_payment_method_not_attached_to_customer():
    pm = payment_method(customer=None)
    assert decide(token(token="pm_123"), pm)[0] == "drop"


def test_drop_legacy_source_token():
    assert decide(token(token="src_abc"), source())[0] == "drop"


def test_drop_legacy_card_token():
    assert decide(token(token="card_abc"), None)[0] == "drop"


def test_drop_legacy_source_no_longer_chargeable():
    assert decide(token(token="src_abc"), source(status="consumed"))[0] == "drop"


def test_skip_when_no_gateway_id():
    assert decide(token(token=""), None)[0] == "skip"


def test_skip_unrecognized_token_shape():
    assert decide(token(token="tok_weird"), None)[0] == "skip"
repair-legacy-tokens.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, gatewayIdOf, isLegacyShaped, isPaymentMethodShaped } from "./repair-legacy-tokens.js";

const token = (over = {}) => ({ id: 1, token: "pm_123", is_default: false, ...over });
const paymentMethod = (over = {}) => ({ object: "payment_method", id: "pm_123", customer: "cus_1", ...over });
const source = (over = {}) => ({ object: "source", id: "src_123", status: "chargeable", ...over });

test("keep attached PaymentMethod", () => {
  assert.equal(decide(token({ token: "pm_123" }), paymentMethod())[0], "keep");
});

test("drop PaymentMethod missing on Stripe", () => {
  assert.equal(decide(token({ token: "pm_123" }), null)[0], "drop");
});

test("drop PaymentMethod not attached to a customer", () => {
  assert.equal(decide(token({ token: "pm_123" }), paymentMethod({ customer: null }))[0], "drop");
});

test("drop legacy Source token", () => {
  assert.equal(decide(token({ token: "src_abc" }), source())[0], "drop");
});

test("drop legacy card token", () => {
  assert.equal(decide(token({ token: "card_abc" }), null)[0], "drop");
});

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

Case studies

Checkout migration

The subscription box that lost half its renewals

A subscription store moved to the new Payment Element checkout on a Friday. By Monday, renewal failures had tripled. Every failed renewal traced back to a customer whose card was saved months earlier through the old checkout, as a bare Source token nobody had ever attached to a Stripe Customer.

Running the script in dry run listed the exact accounts. After a real run, those customers saw a normal "please re-enter your card" prompt on their next login instead of a mystery decline, and support tickets about "my card stopped working" stopped within a day.

Partial migration

The store that thought it had already migrated everyone

A merchant assumed an earlier one-off migration script had converted every saved card to a PaymentMethod. It had, mostly, but a batch of PaymentMethods from that run were created without ever being attached to the matching Stripe Customer, so they failed the exact same way an old Source would.

Because the script checks attachment, not just the token prefix, it caught these too and cleared them alongside the genuinely legacy tokens, closing a gap the first migration had missed.

What good looks like

After this runs once, every saved card left on the account is a real, attached PaymentMethod the new checkout can charge with confidence. Shoppers with a stale token get one clean prompt to re-enter their card, not a repeating decline. Keep the script handy to run again after any future gateway change.

FAQ

Why did my customers' saved cards stop working after I upgraded checkout?

Cards saved in the old checkout were stored as Stripe Source or Card tokens, not as PaymentMethod objects attached to a Stripe Customer. The new SCA-ready checkout only trusts attached PaymentMethods, so it rejects the old token with an error like PaymentMethod was previously used without being attached to a Customer and Setup Intent.

Can I automatically convert every old saved card to a new one?

Not safely. Stripe cannot mint a brand new attached PaymentMethod from a raw card number you never stored, since you never had one to begin with, only a token. The reliable fix is to detect the tokens that will fail and clear them so the shopper is prompted to re-enter their card once, at their next purchase.

Is it safe to remove a saved card token with a script?

Yes, when the script only removes tokens it confirmed are legacy Source or Card ids, or PaymentMethods Stripe no longer recognizes or that are not attached to a customer. It never touches a healthy attached PaymentMethod. Start in dry run mode to review the list before it deletes anything.

Related field notes

Citations

On the problem:

  1. Stripe docs: Sources for card payments are on a deprecation path in favor of PaymentMethods. docs.stripe.com/sources/cards
  2. Stripe docs: Strong Customer Authentication requires a PaymentMethod attached to a Customer for off-session reuse. docs.stripe.com/strong-customer-authentication
  3. WooCommerce docs: saved payment methods and how tokens are stored per customer. woocommerce.com/document/woopayments/subscriptions

On the solution:

  1. Stripe docs: migrating Sources to PaymentMethods and attaching them to a Customer. docs.stripe.com/payments/sources-cards-migration
  2. Stripe API: retrieve a PaymentMethod and check its attached customer. docs.stripe.com/api/payment_methods/retrieve
  3. WooCommerce REST API: customers, payment tokens, and customer notes. 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 checkout upgrade?

If this saved you a pile of support tickets about "my card stopped working," 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