Repair Customers, cards, and tokens

WooCommerce Subscriptions will not let the customer change the card a second time

A customer updates the card on their subscription. It works, the confirmation email goes out, everyone moves on. A month later they try to update it again, maybe because the old card expired, and this time nothing happens. The form submits, the page reloads, and the subscription is still charging the same dead card. Here is why the second change fails and a small script that finds every subscription in this state and clears the block.

Python and Node.js Runs on a schedule Safe by default (dry run)
Holding a leather card wallet
Photo by Emil Kalibradov on Unsplash
The short answer

The subscription is holding onto a Stripe PaymentMethod id that no longer belongs to the Stripe Customer. That happens when the previously saved card is detached in Stripe, by a cleanup script, a duplicate customer merge, or the shopper removing it from their Stripe-hosted wallet, without WooCommerce ever hearing about it. The next "change payment method" attempt tries to swap out that dead id and Stripe rejects the request, so WooCommerce silently keeps the old token. Run a small Python or Node.js script on a schedule that checks each active subscription's saved PaymentMethod against the Stripe Customer's real attached methods, and clears the stale token so the customer's next attempt succeeds. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions does not store a raw card number. It stores a reference, a Stripe PaymentMethod id, in subscription meta such as _stripe_source_id or _payment_method_token, alongside a matching Stripe Customer id. When the shopper changes their card, the gateway attaches the new PaymentMethod to that same Customer and swaps the saved reference.

The trouble starts when the reference on file stops matching reality. If the PaymentMethod that the subscription still remembers gets detached from the Customer, whether by a cleanup job, a merge of two duplicate Stripe Customers, or the shopper deleting the card from a customer portal, WooCommerce has no way to know. The subscription looks fine right up until someone tries to change the card again. That update flow often needs the old PaymentMethod to confirm the swap or to detach it cleanly, and when Stripe returns "no such payment_method" the plugin logs a quiet error and leaves the subscription exactly as it was, still pointed at the dead id.

First card change saved, works fine PaymentMethod saved on subscription meta detached outside WooCommerce PaymentMethod gone cleanup, merge, or removal 2nd change fails no such payment_method Stuck old card stays
The first change succeeds and stores a reference. Something detaches that reference behind WooCommerce's back. The next change attempt fails quietly against the dead reference, and the subscription is left charging a card the customer thought they replaced.

Why it happens

This is not a one-off glitch, it comes from a handful of well known gaps between Stripe and WooCommerce Subscriptions:

Whatever the original cause, the visible symptom is the same: the "change payment method" screen accepts the new card details, shows no error to the shopper, and the subscription keeps renewing against a PaymentMethod that Stripe no longer recognizes.

The key insight

Stripe's list of PaymentMethods attached to a Customer is the source of truth. If a subscription's saved PaymentMethod id is not in that list, or belongs to a different Customer, the subscription cannot possibly be charged with it and every future change attempt will keep failing the same way. Clearing the stale reference, not guessing a replacement, is what unblocks the customer.

The fix, as a flow

We do not touch the checkout or the change payment method form. We add a job that runs on a schedule, reads each active or on-hold subscription's saved Stripe Customer and PaymentMethod id from WooCommerce, and asks Stripe whether that PaymentMethod is still attached to that Customer. If it is not, the reference is stale and blocking every future change. We clear the stale token from the subscription and add a note so the shop manager knows to prompt the customer for a fresh card, the same repair a support agent would do by hand, just consistent and on time.

Scheduled job every hour List active and on-hold subscriptions Read saved id customer + PaymentMethod Still attached on Stripe? yes no, stale Clear stale token add note, flag for new card
The script only acts when Stripe confirms the saved PaymentMethod is no longer attached to the subscription's Customer. Everything still valid is left alone.

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 subscriptions and orders, since WooCommerce Subscriptions exposes subscriptions through the same order endpoints. 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 active subscriptions and read the saved reference

Page through subscriptions that are active or on-hold, since those are the ones a customer might try to update. Each subscription stores its Stripe Customer id and its saved PaymentMethod id in meta, most commonly _stripe_customer_id and _stripe_source_id, with older stores sometimes using _payment_method_token.

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 active_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,on-hold", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            yield sub
        page += 1


def saved_payment_ref(sub):
    """(customer_id, payment_method_id) saved on the subscription, or (None, None)."""
    meta = {m.get("key"): m.get("value") for m in sub.get("meta_data") or []}
    customer_id = meta.get("_stripe_customer_id")
    pm_id = meta.get("_stripe_source_id") or meta.get("_payment_method_token")
    return customer_id, pm_id
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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* activeSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}

function savedPaymentRef(sub) {
  const meta = Object.fromEntries((sub.meta_data || []).map((m) => [m.key, m.value]));
  const customerId = meta._stripe_customer_id || null;
  const pmId = meta._stripe_source_id || meta._payment_method_token || null;
  return { customerId, pmId };
}
3

Ask Stripe whether the PaymentMethod is still attached

Retrieve the PaymentMethod from Stripe. A missing PaymentMethod, or one whose customer field does not match the subscription's saved Stripe Customer id, means the reference is stale. This is the only network call that matters for the decision, everything else is bookkeeping.

step3.py
import stripe

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

def get_payment_method(pm_id):
    if not pm_id:
        return None
    try:
        return stripe.PaymentMethod.retrieve(pm_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function getPaymentMethod(pmId) {
  if (!pmId) return null;
  try {
    return await stripe.paymentMethods.retrieve(pmId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the subscription's saved reference and the Stripe PaymentMethod (or None) and returns an action. If there was never a saved reference, skip it, there is nothing to repair. If the PaymentMethod is missing, or attached to a different Customer than the one on the subscription, the reference is stale and blocking future changes, so we clear it. Otherwise the reference is fine and we leave it alone.

decide.py
def decide(customer_id, pm_id, payment_method):
    if not pm_id or not customer_id:
        return ("skip", "no saved payment reference on this subscription")
    if payment_method is None:
        return ("clear", "saved PaymentMethod no longer exists in Stripe")
    if payment_method.get("customer") != customer_id:
        return ("clear", "saved PaymentMethod is no longer attached to this Stripe Customer")
    return ("ok", "saved PaymentMethod is still attached and valid")
decide.js
export function decide(customerId, pmId, paymentMethod) {
  if (!pmId || !customerId) return ["skip", "no saved payment reference on this subscription"];
  if (!paymentMethod) return ["clear", "saved PaymentMethod no longer exists in Stripe"];
  if (paymentMethod.customer !== customerId) {
    return ["clear", "saved PaymentMethod is no longer attached to this Stripe Customer"];
  }
  return ["ok", "saved PaymentMethod is still attached and valid"];
}
5

Clear the stale token and flag the subscription

When the action is clear, remove the dead _stripe_source_id and _payment_method_token meta from the subscription so the change payment method form has nothing stale to fight against, and add a note explaining what was found. We do not guess a new card, only a real checkout by the customer can provide one, but clearing the block means their very next attempt will finally go through.

apply.py
def clear_stale_token(subscription_id, pm_id, reason):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"meta_data": [
            {"key": "_stripe_source_id", "value": ""},
            {"key": "_payment_method_token", "value": ""},
        ]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": f"Cleared stale saved card {pm_id}: {reason}. "
                      f"The customer will need to add a new card on their next change "
                      f"payment method attempt."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function clearStaleToken(subscriptionId, pmId, reason) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_stripe_source_id", value: "" },
        { key: "_payment_method_token", value: "" },
      ],
    }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Cleared stale saved card ${pmId}: ${reason}. ` +
            `The customer will need to add a new card on their next change payment method attempt.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Leave DRY_RUN on for the first few runs so the script only reports which subscriptions it would clear. Once the list matches what support has been hearing about, switch it off. Run it hourly with cron, this is not urgent enough to need minute-level scheduling.

Run it safe

Always start with DRY_RUN=true. Clearing a payment reference is a one-way action for that token, so you want to see the exact list of affected subscriptions before it writes anything.

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 a reference it has confirmed is dead in Stripe.

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

clear_stale_card.py
"""Find WooCommerce Subscriptions stuck on a stale saved card and clear the block.

A subscription can end up pointing at a Stripe PaymentMethod that no longer
exists or no longer belongs to its Stripe Customer, for example after a
cleanup script or a customer portal removal. When that happens, the next
attempt to change the card fails silently and the subscription is stuck.
This walks active subscriptions, checks each saved reference against Stripe,
and clears any reference that is confirmed dead. Safe to run again and again.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

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

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"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


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


def saved_payment_ref(sub):
    """(customer_id, payment_method_id) saved on the subscription, or (None, None)."""
    meta = {m.get("key"): m.get("value") for m in sub.get("meta_data") or []}
    customer_id = meta.get("_stripe_customer_id")
    pm_id = meta.get("_stripe_source_id") or meta.get("_payment_method_token")
    return customer_id, pm_id


def get_payment_method(pm_id):
    if not pm_id:
        return None
    try:
        return stripe.PaymentMethod.retrieve(pm_id)
    except stripe.error.InvalidRequestError:
        return None


def decide(customer_id, pm_id, payment_method):
    if not pm_id or not customer_id:
        return ("skip", "no saved payment reference on this subscription")
    if payment_method is None:
        return ("clear", "saved PaymentMethod no longer exists in Stripe")
    if payment_method.get("customer") != customer_id:
        return ("clear", "saved PaymentMethod is no longer attached to this Stripe Customer")
    return ("ok", "saved PaymentMethod is still attached and valid")


def clear_stale_token(subscription_id, pm_id, reason):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"meta_data": [
            {"key": "_stripe_source_id", "value": ""},
            {"key": "_payment_method_token", "value": ""},
        ]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": f"Cleared stale saved card {pm_id}: {reason}. "
                      f"The customer will need to add a new card on their next change "
                      f"payment method attempt."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    cleared = 0
    for sub in active_subscriptions():
        customer_id, pm_id = saved_payment_ref(sub)
        payment_method = get_payment_method(pm_id)
        action, reason = decide(customer_id, pm_id, payment_method)
        if action != "clear":
            continue
        log.info("Subscription %s: %s. %s", sub["id"], reason, "would clear" if DRY_RUN else "clearing")
        if not DRY_RUN:
            clear_stale_token(sub["id"], pm_id, reason)
        cleared += 1
    log.info("Done. %d subscription(s) %s.", cleared, "to clear" if DRY_RUN else "cleared")


if __name__ == "__main__":
    run()
clear-stale-card.js
/**
 * Find WooCommerce Subscriptions stuck on a stale saved card and clear the block.
 *
 * A subscription can end up pointing at a Stripe PaymentMethod that no longer
 * exists or no longer belongs to its Stripe Customer, for example after a
 * cleanup script or a customer portal removal. When that happens, the next
 * attempt to change the card fails silently and the subscription is stuck.
 * This walks active subscriptions, checks each saved reference against Stripe,
 * and clears any reference that is confirmed dead. Safe to run again and again.
 */
import Stripe from "stripe";

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 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();
}

async function* activeSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}

function savedPaymentRef(sub) {
  const meta = Object.fromEntries((sub.meta_data || []).map((m) => [m.key, m.value]));
  const customerId = meta._stripe_customer_id || null;
  const pmId = meta._stripe_source_id || meta._payment_method_token || null;
  return { customerId, pmId };
}

async function getPaymentMethod(pmId) {
  if (!pmId) return null;
  try {
    return await stripe.paymentMethods.retrieve(pmId);
  } catch {
    return null;
  }
}

function decide(customerId, pmId, paymentMethod) {
  if (!pmId || !customerId) return ["skip", "no saved payment reference on this subscription"];
  if (!paymentMethod) return ["clear", "saved PaymentMethod no longer exists in Stripe"];
  if (paymentMethod.customer !== customerId) {
    return ["clear", "saved PaymentMethod is no longer attached to this Stripe Customer"];
  }
  return ["ok", "saved PaymentMethod is still attached and valid"];
}

async function clearStaleToken(subscriptionId, pmId, reason) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_stripe_source_id", value: "" },
        { key: "_payment_method_token", value: "" },
      ],
    }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Cleared stale saved card ${pmId}: ${reason}. ` +
            `The customer will need to add a new card on their next change payment method attempt.`,
    }),
  });
}

async function run() {
  let cleared = 0;
  for await (const sub of activeSubscriptions()) {
    const { customerId, pmId } = savedPaymentRef(sub);
    const paymentMethod = await getPaymentMethod(pmId);
    const [action, reason] = decide(customerId, pmId, paymentMethod);
    if (action !== "clear") continue;
    console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would clear" : "clearing"}`);
    if (!DRY_RUN) await clearStaleToken(sub.id, pmId, reason);
    cleared++;
  }
  console.log(`Done. ${cleared} subscription(s) ${DRY_RUN ? "to clear" : "cleared"}.`);
}

run().catch((err) => { console.error(err); process.exit(1); });

Add a test

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

test_decide.py
from clear_stale_card import decide


def pm(**over):
    base = {"customer": "cus_1"}
    base.update(over)
    return base


def test_ok_when_still_attached():
    assert decide("cus_1", "pm_1", pm())[0] == "ok"


def test_clear_when_payment_method_missing():
    assert decide("cus_1", "pm_1", None)[0] == "clear"


def test_clear_when_attached_to_different_customer():
    assert decide("cus_1", "pm_1", pm(customer="cus_2"))[0] == "clear"


def test_skip_when_nothing_saved():
    assert decide(None, None, None)[0] == "skip"
decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./decide.js";

const pm = (over = {}) => ({ customer: "cus_1", ...over });

test("ok when still attached", () => {
  assert.equal(decide("cus_1", "pm_1", pm())[0], "ok");
});

test("clear when payment method missing", () => {
  assert.equal(decide("cus_1", "pm_1", null)[0], "clear");
});

test("clear when attached to a different customer", () => {
  assert.equal(decide("cus_1", "pm_1", pm({ customer: "cus_2" }))[0], "clear");
});

test("skip when nothing saved", () => {
  assert.equal(decide(null, null, null)[0], "skip");
});

Case studies

Duplicate customer cleanup

The merge that orphaned two hundred cards

A store had accumulated duplicate Stripe Customers from an old checkout bug and ran a cleanup script to merge them, detaching the PaymentMethods on the losing side of each merge. About two hundred active subscriptions still pointed at those now-detached PaymentMethod ids.

Nothing broke immediately, renewals kept working off cached tokens, but every customer who later tried to update their card hit a silent failure. The script found all two hundred in dry run, support emailed each customer proactively, and the queue of "your card update did not work" tickets dropped to zero within a week.

Customer portal

The shopper who deleted their own card

A customer used a separate self-serve billing portal (built on the same Stripe account) to clean up old cards, not realizing one of them was still attached to a live subscription. Their next attempt to change the card on the WooCommerce side failed with no visible error.

The hourly run caught the stale reference the same day, cleared it, and left a note. The following billing cycle the customer was prompted for a card on the retry and the subscription moved to a healthy PaymentMethod without a single support ticket.

What good looks like

After this runs on a schedule, a subscription can never stay silently stuck on a dead card reference for long. The worst case becomes a short window before the next run clears the block and the customer's retry goes through cleanly. Keep it running even after the cleanup script is fixed, because a shopper deleting their own card from a portal is outside your control either way.

FAQ

Why does changing the card on a subscription work once but fail the second time?

The first change usually succeeds and WooCommerce saves the new Stripe PaymentMethod id on the subscription. If that PaymentMethod later gets detached from the Stripe Customer, for example by a cleanup script, a duplicate customer merge, or the customer removing the card in Stripe, the subscription meta still points at a dead id. The next change attempt tries to reuse that dead id as the old token and Stripe rejects it, so the update silently fails and the subscription is stuck.

Is it safe to fix this with a script instead of asking the customer to try again?

Yes, when the script only touches subscriptions where the saved PaymentMethod id no longer exists or no longer belongs to the Stripe Customer on file, and it never guesses a replacement card. It clears the stale token and flags the subscription for a fresh card so the customer's next attempt goes through cleanly. Start in dry run mode to review the list first.

How do I stop this from happening again?

Run the same check on a schedule so a detached PaymentMethod is caught within minutes instead of waiting for the customer to hit the wall. It is also worth checking your cleanup and deduplication scripts for any step that detaches Stripe PaymentMethods without first checking whether a live WooCommerce subscription still points at them.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how the saved payment token is stored and used for automatic renewals. woocommerce.com/document/subscriptions/store-manager-guide
  2. Stripe docs: detaching a PaymentMethod from a Customer and what happens to references that still point at it. docs.stripe.com/api/payment_methods/detach
  3. WooCommerce Stripe gateway issue tracker: change payment method failing silently when the previous token is invalid. github.com/woocommerce/woocommerce-gateway-stripe/issues

On the solution:

  1. Stripe API: retrieve a PaymentMethod and check its current attached Customer. docs.stripe.com/api/payment_methods/retrieve
  2. WooCommerce REST API: list and update subscriptions through the same order-shaped endpoints. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce Subscriptions developer docs: subscription meta keys used by payment gateways to store tokens. woocommerce.com/document/subscriptions/develop/payment-gateway-integration

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 unblock a stuck subscription?

If this saved you a pile of support tickets or an angry customer, 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