Diagnostic Customers, cards, and tokens

WooCommerce renewals fail because the Stripe payment method was detached

A subscription that renewed fine for months suddenly starts failing with a plain, unhelpful decline. Nothing changed on the order. Nothing changed in WooCommerce. What changed is on Stripe, the saved card was detached from the customer, so there is nothing left to charge. Here is why that happens and a small script that finds every subscription this hits and flags it so a human can ask for a new card.

Python and Node.js Runs on a schedule Safe by default (dry run)
Person holding black android smartphone
Photo by naipo.de on Unsplash
The short answer

The subscription's saved card is a Stripe PaymentMethod attached to a Customer, and that PaymentMethod has been detached, so renewals have nothing valid to charge. Run a small Python or Node.js script on a schedule that reads the PaymentIntent id from the last renewal order's _stripe_intent_id meta (or transaction_id), asks Stripe for the payment method behind it, and flags the subscription when that payment method is missing or no longer attached to the right customer. Stripe will not let you reattach a payment method once it is detached, so the fix is a note and an on-hold status, not a silent repair. Full code, tests, and a dry run guard are below.

The problem in plain words

A saved card for automatic billing is not really "stored" inside WooCommerce. WooCommerce keeps a reference, a Stripe customer id and a payment method id, and the actual card lives on Stripe as a PaymentMethod object attached to that customer. As long as the attachment holds, the renewal can charge it without the shopper doing anything.

Sometimes that attachment breaks. The PaymentMethod gets detached from the customer, and from that moment on, every renewal attempt fails the same way. The order shows a generic decline. Nothing in the WooCommerce admin explains why, because WooCommerce only sees the failed charge, not the missing attachment that caused it.

Renewal is due tries saved card Card was detached no customer on it nothing to charge Renewal fails generic decline Sub on-hold fails again
The renewal tries to charge a payment method that no longer has a customer attached. It cannot succeed no matter how many times it retries.

Why it happens

A PaymentMethod does not detach itself, something acts on it. The common causes seen in real stores:

Whatever the cause, Stripe's own rule makes this permanent for that PaymentMethod: once detached, it cannot be reattached through the API. The only way forward is a new PaymentMethod, which means the shopper has to come back and enter a card again.

The key insight

This is not a case where a script can quietly fix things. A detached PaymentMethod is a dead end on Stripe's side, by design. The useful job for automation is detection, find every subscription whose saved payment method is gone or reattached elsewhere, and flag it clearly, so a human can reach out and collect a new card before the shopper notices only when the service stops.

The fix, as a flow

We do not touch checkout or renewals directly. We add a job that runs on a schedule, walks subscriptions that are active or on-hold, and for each one looks at its most recent renewal order. That order carries the PaymentIntent id that was actually charged, in the _stripe_intent_id meta or the transaction_id field. We ask Stripe what payment method that PaymentIntent tried to use, and check whether it is still attached to the subscription's customer. If it is missing or attached elsewhere, we add a note and put the subscription on-hold so someone follows up.

Scheduled job active subs Latest renewal read PaymentIntent id Ask Stripe for the payment method Still attached to this customer? yes ok, skip no Flag + on-hold ask for a new card
The check only reads from Stripe and WooCommerce. When the payment method is confirmed gone or reattached elsewhere, the only write is a note and an on-hold status, never a silent retry.

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 access to orders and subscriptions, and write access to add notes and change status. 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 subscriptions that could be affected

Only subscriptions that are still active or on-hold are worth checking. Anything cancelled or already ended has no future renewal to protect. Page through the WooCommerce Subscriptions REST endpoint and keep those two statuses.

step2.py
import requests
from requests.auth import HTTPBasicAuth

AUTH = HTTPBasicAuth(WOO_CONSUMER_KEY, 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
step2.js
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++;
  }
}
3

Read the PaymentIntent from the last renewal order

The most recent renewal order tells us exactly which payment method Stripe actually tried to charge, which is more reliable than trusting whatever id is cached on the subscription itself. The WooCommerce Stripe gateway saves the PaymentIntent id in order meta under _stripe_intent_id. Older orders sometimes only have it in transaction_id, so we fall back to that.

step3.py
def intent_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def latest_renewal_order(subscription_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"subscription_renewal": subscription_id, "per_page": 1,
                "orderby": "date", "order": "desc"},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    batch = r.json()
    return batch[0] if batch else None
step3.js
function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

async function latestRenewalOrder(subscriptionId) {
  const batch = await woo(
    `/orders?subscription_renewal=${subscriptionId}&per_page=1&orderby=date&order=desc`
  );
  return batch[0] || null;
}
4

Ask Stripe for the payment method behind that intent

Retrieve the PaymentIntent, then look at its payment_method field, falling back to the payment method on last_payment_error if the intent never fully attached one. Retrieve that PaymentMethod object directly, since only the PaymentMethod itself tells you whether it is still attached to a customer.

step4.py
import stripe

def get_payment_method_for_intent(intent_id):
    if not intent_id:
        return None
    try:
        intent = stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
    pm_id = intent.get("payment_method") or (
        intent.get("last_payment_error") or {}
    ).get("payment_method", {}).get("id")
    if not pm_id:
        return None
    try:
        return stripe.PaymentMethod.retrieve(pm_id)
    except stripe.error.InvalidRequestError:
        return None
step4.js
async function getPaymentMethodForIntent(intentId) {
  if (!intentId) return null;
  let intent;
  try {
    intent = await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
  const pmId = intent.payment_method || intent.last_payment_error?.payment_method?.id;
  if (!pmId) return null;
  try {
    return await stripe.paymentMethods.retrieve(pmId);
  } catch {
    return null;
  }
}
5

Decide, with one pure function

Keep the decision in its own function that takes the subscription, its latest renewal order, and the PaymentMethod, and returns an action. This is what we test later, with no network involved. Skip subscriptions that are not active, that have no renewal yet, or whose renewal order has no PaymentIntent id to check. Flag anything where the payment method is missing, detached, or attached to a different customer than expected.

decide.py
ACTIVE_STATUSES = {"active", "on-hold"}

def decide(subscription, renewal_order, payment_method):
    if subscription["status"] not in ACTIVE_STATUSES:
        return ("skip", "subscription is not active or on-hold")
    if renewal_order is None:
        return ("skip", "no renewal order to check yet")
    if intent_id_of(renewal_order) is None:
        return ("skip", "renewal order has no saved PaymentIntent id")
    if payment_method is None:
        return ("flag", "saved payment method no longer exists on Stripe")
    if payment_method.get("customer") is None:
        return ("flag", "payment method is detached from any Stripe customer")
    expected_customer = subscription.get("stripe_customer_id")
    if expected_customer and payment_method["customer"] != expected_customer:
        return ("flag", "payment method is attached to a different Stripe customer")
    return ("ok", "payment method is attached and matches the subscription")
decide.js
const ACTIVE_STATUSES = new Set(["active", "on-hold"]);

export function decide(subscription, renewalOrder, paymentMethod) {
  if (!ACTIVE_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active or on-hold"];
  }
  if (!renewalOrder) return ["skip", "no renewal order to check yet"];
  if (!intentIdOf(renewalOrder)) return ["skip", "renewal order has no saved PaymentIntent id"];
  if (!paymentMethod) return ["flag", "saved payment method no longer exists on Stripe"];
  if (!paymentMethod.customer) return ["flag", "payment method is detached from any Stripe customer"];
  const expectedCustomer = subscription.stripe_customer_id;
  if (expectedCustomer && paymentMethod.customer !== expectedCustomer) {
    return ["flag", "payment method is attached to a different Stripe customer"];
  }
  return ["ok", "payment method is attached and matches the subscription"];
}
6

Flag it, do not try to fix the card

When the action is flag, add an order note explaining what failed in plain words, and put the subscription on-hold so it stops retrying a card that can never succeed. This is intentionally the only write the script makes. There is no code path that tries to reattach a payment method, because Stripe does not support it.

Run it safe

Always start with DRY_RUN=true. Even though the only write here is a note and an on-hold status, you still want to see the exact list of subscriptions it plans to touch before it touches anything.

The full code

Here is the complete check in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never attempts to charge or repair a payment method, only to detect and flag.

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

find_detached_payment_methods.py
"""Find WooCommerce subscriptions whose saved Stripe payment method was detached.

A saved card lives on Stripe as a PaymentMethod attached to a Customer. If that
PaymentMethod gets detached, by the shopper removing it in a self-service portal,
by a cleanup script that ran against the wrong customer, or by a support agent
clearing "duplicate" cards, the next renewal fails with a generic decline and the
subscription goes on-hold. Stripe will not let you reattach a PaymentMethod once it
is detached, so there is nothing to repair automatically. This script only detects
the problem and flags the subscription so a human can ask the shopper for a new card.

It walks subscriptions that are active or on-hold, reads the PaymentIntent id from
the latest renewal order's meta (_stripe_intent_id, falling back to transaction_id),
asks Stripe for the payment_method that PaymentIntent tried to use, and checks
whether that PaymentMethod is still attached to the subscription's Stripe customer.
Read only by default. Run 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("find_detached_payment_methods")

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"

ACTIVE_STATUSES = {"active", "on-hold"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def decide(subscription, renewal_order, payment_method):
    """Pure decision function. No I/O.

    subscription: dict with at least "id" and "status".
    renewal_order: the subscription's most recent renewal order dict, or None
                    if there is no renewal order yet.
    payment_method: the Stripe PaymentMethod dict the renewal tried to charge,
                     or None if it could not be found on Stripe at all.
    """
    if subscription["status"] not in ACTIVE_STATUSES:
        return ("skip", "subscription is not active or on-hold")
    if renewal_order is None:
        return ("skip", "no renewal order to check yet")
    if intent_id_of(renewal_order) is None:
        return ("skip", "renewal order has no saved PaymentIntent id")
    if payment_method is None:
        return ("flag", "saved payment method no longer exists on Stripe")
    if payment_method.get("customer") is None:
        return ("flag", "payment method is detached from any Stripe customer")
    expected_customer = subscription.get("stripe_customer_id")
    if expected_customer and payment_method["customer"] != expected_customer:
        return ("flag", "payment method is attached to a different Stripe customer")
    return ("ok", "payment method is attached and matches the subscription")


def get_payment_method_for_intent(intent_id):
    """Look up the PaymentMethod a PaymentIntent tried to charge, if any."""
    if not intent_id:
        return None
    try:
        intent = stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
    pm_id = intent.get("payment_method") or (
        intent.get("last_payment_error") or {}
    ).get("payment_method", {}).get("id")
    if not pm_id:
        return None
    try:
        return stripe.PaymentMethod.retrieve(pm_id)
    except stripe.error.InvalidRequestError:
        return None


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 latest_renewal_order(subscription_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"subscription_renewal": subscription_id, "per_page": 1, "orderby": "date", "order": "desc"},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    batch = r.json()
    return batch[0] if batch else None


def flag(subscription, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{subscription['id']}/notes",
        json={"note": f"Payment method check failed: {reason}. The saved card can no "
                      f"longer be charged automatically. Please ask the customer to "
                      f"add a new payment method."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{subscription['id']}",
        json={"status": "on-hold"}, auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    flagged = 0
    for subscription in active_subscriptions():
        renewal_order = latest_renewal_order(subscription["id"])
        intent_id = intent_id_of(renewal_order) if renewal_order else None
        payment_method = get_payment_method_for_intent(intent_id)
        action, reason = decide(subscription, renewal_order, payment_method)
        if action != "flag":
            continue
        log.warning("Subscription %s: %s. %s", subscription["id"], reason,
                    "would flag" if DRY_RUN else "flagging")
        if not DRY_RUN:
            flag(subscription, reason)
        flagged += 1
    log.info("Done. %d subscription(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
find-detached-payment-methods.js
/**
 * Find WooCommerce subscriptions whose saved Stripe payment method was detached.
 *
 * A saved card lives on Stripe as a PaymentMethod attached to a Customer. If that
 * PaymentMethod gets detached, by the shopper removing it in a self-service portal,
 * by a cleanup script that ran against the wrong customer, or by a support agent
 * clearing "duplicate" cards, the next renewal fails with a generic decline and the
 * subscription goes on-hold. Stripe will not let you reattach a PaymentMethod once
 * it is detached, so there is nothing to repair automatically. This script only
 * detects the problem and flags the subscription so a human can ask the shopper
 * for a new card.
 *
 * It walks subscriptions that are active or on-hold, reads the PaymentIntent id
 * from the latest renewal order's meta (_stripe_intent_id, falling back to
 * transaction_id), asks Stripe for the payment_method that PaymentIntent tried to
 * use, and checks whether that PaymentMethod is still attached to the
 * subscription's Stripe customer. Read only by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/payment-method-detached/
 */
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 ACTIVE_STATUSES = new Set(["active", "on-hold"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

/**
 * Pure decision function. No I/O.
 *
 * subscription: object with at least "id" and "status".
 * renewalOrder: the subscription's most recent renewal order object, or null
 *               if there is no renewal order yet.
 * paymentMethod: the Stripe PaymentMethod object the renewal tried to charge,
 *                or null if it could not be found on Stripe at all.
 */
export function decide(subscription, renewalOrder, paymentMethod) {
  if (!ACTIVE_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not active or on-hold"];
  }
  if (!renewalOrder) return ["skip", "no renewal order to check yet"];
  if (!intentIdOf(renewalOrder)) return ["skip", "renewal order has no saved PaymentIntent id"];
  if (!paymentMethod) return ["flag", "saved payment method no longer exists on Stripe"];
  if (!paymentMethod.customer) return ["flag", "payment method is detached from any Stripe customer"];
  const expectedCustomer = subscription.stripe_customer_id;
  if (expectedCustomer && paymentMethod.customer !== expectedCustomer) {
    return ["flag", "payment method is attached to a different Stripe customer"];
  }
  return ["ok", "payment method is attached and matches the subscription"];
}

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 getPaymentMethodForIntent(intentId) {
  if (!intentId) return null;
  let intent;
  try {
    intent = await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
  const pmId = intent.payment_method || intent.last_payment_error?.payment_method?.id;
  if (!pmId) return null;
  try {
    return await stripe.paymentMethods.retrieve(pmId);
  } catch {
    return null;
  }
}

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++;
  }
}

async function latestRenewalOrder(subscriptionId) {
  const batch = await woo(
    `/orders?subscription_renewal=${subscriptionId}&per_page=1&orderby=date&order=desc`
  );
  return batch[0] || null;
}

async function flag(subscription, reason) {
  await woo(`/orders/${subscription.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Payment method check failed: ${reason}. The saved card can no longer ` +
            `be charged automatically. Please ask the customer to add a new payment method.`,
    }),
  });
  await woo(`/orders/${subscription.id}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
}

export async function run() {
  let flagged = 0;
  for await (const subscription of activeSubscriptions()) {
    const renewalOrder = await latestRenewalOrder(subscription.id);
    const intentId = renewalOrder ? intentIdOf(renewalOrder) : null;
    const paymentMethod = await getPaymentMethodForIntent(intentId);
    const [action, reason] = decide(subscription, renewalOrder, paymentMethod);
    if (action !== "flag") continue;
    console.warn(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
    if (!DRY_RUN) await flag(subscription, reason);
    flagged++;
  }
  console.log(`Done. ${flagged} subscription(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides which subscriptions get flagged and put on-hold. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_detached_pm_decide.py
from find_detached_payment_methods import decide, intent_id_of


def subscription(**over):
    base = {"id": 501, "status": "active", "stripe_customer_id": "cus_1"}
    base.update(over)
    return base


def renewal_order(**over):
    base = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_1"}], "transaction_id": ""}
    base.update(over)
    return base


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


def test_ok_when_attached_to_expected_customer():
    assert decide(subscription(), renewal_order(), payment_method())[0] == "ok"


def test_flag_when_payment_method_missing():
    assert decide(subscription(), renewal_order(), None)[0] == "flag"


def test_flag_when_payment_method_detached():
    pm = payment_method(customer=None)
    assert decide(subscription(), renewal_order(), pm)[0] == "flag"


def test_flag_when_attached_to_different_customer():
    pm = payment_method(customer="cus_999")
    assert decide(subscription(), renewal_order(), pm)[0] == "flag"


def test_skip_when_subscription_not_active():
    sub = subscription(status="cancelled")
    assert decide(sub, renewal_order(), payment_method())[0] == "skip"


def test_skip_when_no_renewal_order_yet():
    assert decide(subscription(), None, payment_method())[0] == "skip"


def test_skip_when_renewal_order_has_no_intent_id():
    order = renewal_order(meta_data=[], transaction_id="")
    assert decide(subscription(), order, payment_method())[0] == "skip"
find-detached-payment-methods.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./find-detached-payment-methods.js";

const subscription = (over = {}) => ({ id: 501, status: "active", stripe_customer_id: "cus_1", ...over });
const renewalOrder = (over = {}) => ({
  meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }],
  transaction_id: "",
  ...over,
});
const paymentMethod = (over = {}) => ({ id: "pm_1", customer: "cus_1", ...over });

test("ok when attached to expected customer", () => {
  assert.equal(decide(subscription(), renewalOrder(), paymentMethod())[0], "ok");
});

test("flag when payment method missing", () => {
  assert.equal(decide(subscription(), renewalOrder(), null)[0], "flag");
});

test("flag when payment method detached", () => {
  const pm = paymentMethod({ customer: null });
  assert.equal(decide(subscription(), renewalOrder(), pm)[0], "flag");
});

test("flag when attached to a different customer", () => {
  const pm = paymentMethod({ customer: "cus_999" });
  assert.equal(decide(subscription(), renewalOrder(), pm)[0], "flag");
});

test("skip when subscription not active", () => {
  const sub = subscription({ status: "cancelled" });
  assert.equal(decide(sub, renewalOrder(), paymentMethod())[0], "skip");
});

test("skip when no renewal order yet", () => {
  assert.equal(decide(subscription(), null, paymentMethod())[0], "skip");
});

test("skip when renewal order has no intent id", () => {
  const order = renewalOrder({ meta_data: [], transaction_id: "" });
  assert.equal(decide(subscription(), order, paymentMethod())[0], "skip");
});

Case studies

Data cleanup

The privacy script that swept up live customers

A store ran a script to remove old, unused Stripe customers for a data retention policy. The filter was based on account creation date, not subscription status, so it detached payment methods from a handful of customers who still had active subscriptions.

The check caught eleven affected subscriptions on its first run, all with the identical "detached from any Stripe customer" reason, which made it easy to trace back to the cleanup script and fix the filter before it ran again.

Support cleanup

The duplicate card that was not a duplicate

A support agent, trying to tidy a customer's billing page, detached what looked like a duplicate card. It was actually the only payment method behind a different, older subscription for the same person that was not visible on the page they were looking at.

The subscription went on-hold at the next renewal with a clear note, and support only found out from that note, not from an angry email a week later when the service had already lapsed.

What good looks like

After this runs on a schedule, a detached payment method turns into a same-day note and an on-hold status, instead of a mystery decline that a shopper only discovers when their subscription quietly stops working. Nobody has to guess why the renewal failed, because the reason is written right on the order.

FAQ

Why does a WooCommerce subscription renewal fail with a payment method error?

The saved card is stored on Stripe as a PaymentMethod attached to a Customer. If that PaymentMethod is detached, by the shopper removing it, a cleanup script, or a support action, the renewal has nothing valid to charge and fails with a generic decline. A check that reads the PaymentMethod behind the last renewal's PaymentIntent and confirms it is still attached finds every subscription this affects.

Can I just reattach the detached payment method with the API?

No. Stripe does not allow a PaymentMethod to be reattached once it has been detached from a customer. The only real fix is to ask the shopper to add a new card, so the safe move for a script is to detect the problem and flag the subscription, not to try to force a reattachment.

How do I tell a detached payment method apart from a declined card?

A decline still has a payment method attached to a customer, Stripe just refused the charge for that specific attempt. A detached payment method has no customer on the PaymentMethod object at all, or it belongs to a different customer than the subscription expects. Checking the customer field on the PaymentMethod is what tells them apart.

Related field notes

Citations

On the problem:

  1. Stripe docs: the PaymentMethod object, its customer field, and what detaching one means. docs.stripe.com/api/payment_methods/object
  2. Stripe docs: detach a PaymentMethod from a customer, and why it cannot be reattached afterward. docs.stripe.com/api/payment_methods/detach
  3. WooCommerce Subscriptions docs: how renewals use the saved payment token and what happens when it fails. woocommerce.com/document/subscriptions/renewal-process

On the solution:

  1. Stripe API: retrieve a PaymentIntent, including payment_method and last_payment_error. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe API: retrieve a PaymentMethod to check its current customer attachment. docs.stripe.com/api/payment_methods/retrieve
  3. WooCommerce REST API: list orders by subscription renewal, update an order, and add an order note. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this save you a support ticket?

If this helped you catch a wave of silent renewal failures before your shoppers noticed, 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