Reconciler Customers, cards, and tokens
A WooCommerce subscription has no saved card, so every renewal fails
The first payment went through and the customer is happily subscribed, but every renewal fails with "unable to process your payment at this time." The subscription has no saved Stripe customer or card to charge. It usually starts with a declined first attempt and a successful retry that never wrote the token back. This guide recovers the card from the paid order and puts it back on the subscription.
The subscription is missing its Stripe customer and card, so renewals have nothing to charge. The first order was paid, so Stripe still holds both. Find active subscriptions with no _stripe_customer_id, read the customer and payment method from the paid PaymentIntent on the parent order, and write them back onto the subscription. Renewals work again. The full code is below.
The problem in plain words
For a subscription to renew on its own, WooCommerce needs to know which Stripe customer to charge and which saved card to use. Those are stored on the subscription as a customer ID and a card token. When they are missing, there is nothing to charge, so the renewal fails every time with a generic message about not being able to process the payment.
The most common way this happens is a bumpy checkout. The buyer's first card is declined, they try another, and the second one works. The payment succeeds, but the token from that successful attempt is never written onto the subscription, so the connection to Stripe is never made.
Why it happens
The WooCommerce Stripe plugin has reported subscriptions ending up with no valid Stripe connection after a failed then successful checkout, so renewals cannot process. The successful token exists on Stripe but is never written to the subscription. The citations at the end link the threads.
The card is not lost. The first order was paid, and Stripe still holds the customer and the payment method that paid it. The link is only missing on the WooCommerce side. Read the customer and payment method from the paid PaymentIntent on the parent order, and write them back onto the subscription.
The fix, as a flow
We list active subscriptions that have no Stripe customer saved. For each one we look at the parent order, confirm it was paid, and read the Stripe customer and payment method from its PaymentIntent. If both exist, we write them onto the subscription. If Stripe has no reusable card, we flag the subscription so you can ask the customer to add one.
Build it step by step
Find subscriptions that lost their card
Keep the check in one pure function. A subscription needs a backfill when it is paid with Stripe, is active or on-hold, and has no Stripe customer ID saved. Those three together mark a subscription that cannot renew because it has nothing to charge.
def get_meta(record, key):
for m in record.get("meta_data", []):
if m.get("key") == key:
return m.get("value")
return None
def needs_token_backfill(sub):
if not sub["payment_method"].startswith("stripe"):
return False
if sub["status"] not in ("active", "on-hold"):
return False
return not get_meta(sub, "_stripe_customer_id")
export function getMeta(record, key) {
const hit = (record.meta_data || []).find((m) => m.key === key);
return hit ? hit.value : null;
}
export function needsTokenBackfill(sub) {
if (!sub.payment_method.startsWith("stripe")) return false;
if (!["active", "on-hold"].includes(sub.status)) return false;
return !getMeta(sub, "_stripe_customer_id");
}
Read the card from the paid first order
Take the subscription's parent order, confirm it was paid, and read its PaymentIntent. The intent holds the Stripe customer and the payment method that paid it. Both need to exist for the card to be reusable on renewals.
PAID_STATUSES = {"processing", "completed"}
def recover_card(sub):
parent = get_order(sub.get("parent_id"))
if not parent or parent["status"] not in PAID_STATUSES:
return None
intent_id = get_meta(parent, "_stripe_intent_id")
if not intent_id:
return None
intent = stripe.PaymentIntent.retrieve(intent_id)
customer, method = intent.get("customer"), intent.get("payment_method")
if customer and method:
return {"customer": customer, "method": method}
return None
const PAID_STATUSES = new Set(["processing", "completed"]);
async function recoverCard(sub) {
const parent = await woo(`/orders/${sub.parent_id}`);
if (!parent || !PAID_STATUSES.has(parent.status)) return null;
const intentId = getMeta(parent, "_stripe_intent_id");
if (!intentId) return null;
const intent = await stripe.paymentIntents.retrieve(intentId);
const customer = intent.customer, method = intent.payment_method;
return customer && method ? { customer, method } : null;
}
Write the card back onto the subscription
Save the Stripe customer and payment method onto the subscription as meta, so the next renewal has something to charge. Add a note that records the recovery. If no reusable card was found, flag the subscription instead so you can ask the customer to add one.
def backfill_sub(sub_id, card):
requests.put(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"meta_data": [
{"key": "_stripe_customer_id", "value": card["customer"]},
{"key": "_stripe_source_id", "value": card["method"]},
]}, auth=AUTH, timeout=30).raise_for_status()
add_sub_note(sub_id, f"Recovered the Stripe card ({card['method']}) from the first paid order "
f"and put it back on the subscription so renewals can run.")
async function backfillSub(subId, card) {
await woo(`/subscriptions/${subId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: "_stripe_customer_id", value: card.customer },
{ key: "_stripe_source_id", value: card.method },
],
}),
});
await addSubNote(subId, `Recovered the Stripe card (${card.method}) from the first paid order ` +
`and put it back on the subscription so renewals can run.`);
}
Only write a card that Stripe really holds for this customer, read from their own paid order. Never copy a card between customers. Start with DRY_RUN=true and read which subscriptions it would repair and which it would flag for a customer update.
The full code
The complete reconciler lists active subscriptions with no saved card, recovers the customer and card from the paid parent order, and backfills the ones it can. Subscriptions with no reusable card are flagged, not touched.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Recover the Stripe card for WooCommerce subscriptions that lost it,
so automatic renewals can run again.
Run on a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/woocommerce/subscription-missing-saved-card-token/
"""
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("backfill_sub_token")
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"
PAID_STATUSES = {"processing", "completed"}
def get_meta(record, key):
for m in record.get("meta_data", []):
if m.get("key") == key:
return m.get("value")
return None
def needs_token_backfill(sub):
if not sub["payment_method"].startswith("stripe"):
return False
if sub["status"] not in ("active", "on-hold"):
return False
return not get_meta(sub, "_stripe_customer_id")
def 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()
subs = r.json()
if not subs:
return
for sub in subs:
yield sub
page += 1
def get_order(order_id):
if not order_id:
return None
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def recover_card(sub):
parent = get_order(sub.get("parent_id"))
if not parent or parent["status"] not in PAID_STATUSES:
return None
intent_id = get_meta(parent, "_stripe_intent_id")
if not intent_id:
return None
intent = stripe.PaymentIntent.retrieve(intent_id)
customer, method = intent.get("customer"), intent.get("payment_method")
if customer and method:
return {"customer": customer, "method": method}
return None
def backfill_sub(sub_id, card):
requests.put(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"meta_data": [
{"key": "_stripe_customer_id", "value": card["customer"]},
{"key": "_stripe_source_id", "value": card["method"]},
]}, auth=AUTH, timeout=30).raise_for_status()
requests.post(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Recovered the Stripe card ({card['method']}) from the first paid order "
f"and put it back on the subscription so renewals can run."},
auth=AUTH, timeout=30).raise_for_status()
def run():
fixed = flagged = 0
for sub in subscriptions():
if not needs_token_backfill(sub):
continue
card = recover_card(sub)
if not card:
log.warning("Subscription %s has no reusable card on Stripe. Flag for a customer update.", sub["id"])
flagged += 1
continue
log.info("Subscription %s: recovered %s. %s", sub["id"], card["method"], "would backfill" if DRY_RUN else "backfilling")
if not DRY_RUN:
backfill_sub(sub["id"], card)
fixed += 1
log.info("Done. %d backfilled, %d flagged %s.", fixed, flagged, "(dry run)" if DRY_RUN else "")
if __name__ == "__main__":
run()
/**
* Recover the Stripe card for WooCommerce subscriptions that lost it,
* so automatic renewals can run again.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/woocommerce/subscription-missing-saved-card-token/
*/
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";
const PAID_STATUSES = new Set(["processing", "completed"]);
export function getMeta(record, key) {
const hit = (record.meta_data || []).find((m) => m.key === key);
return hit ? hit.value : null;
}
export function needsTokenBackfill(sub) {
if (!sub.payment_method.startsWith("stripe")) return false;
if (!["active", "on-hold"].includes(sub.status)) return false;
return !getMeta(sub, "_stripe_customer_id");
}
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* subscriptions() {
let page = 1;
while (true) {
const subs = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
if (!subs.length) return;
for (const sub of subs) yield sub;
page++;
}
}
async function recoverCard(sub) {
const parent = await woo(`/orders/${sub.parent_id}`);
if (!parent || !PAID_STATUSES.has(parent.status)) return null;
const intentId = getMeta(parent, "_stripe_intent_id");
if (!intentId) return null;
const intent = await stripe.paymentIntents.retrieve(intentId);
const customer = intent.customer, method = intent.payment_method;
return customer && method ? { customer, method } : null;
}
async function backfillSub(subId, card) {
await woo(`/subscriptions/${subId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: "_stripe_customer_id", value: card.customer },
{ key: "_stripe_source_id", value: card.method },
],
}),
});
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Recovered the Stripe card (${card.method}) from the first paid order ` +
`and put it back on the subscription so renewals can run.`,
}),
});
}
async function run() {
let fixed = 0, flagged = 0;
for await (const sub of subscriptions()) {
if (!needsTokenBackfill(sub)) continue;
const card = await recoverCard(sub);
if (!card) {
console.warn(`Subscription ${sub.id} has no reusable card on Stripe. Flag for a customer update.`);
flagged++;
continue;
}
console.log(`Subscription ${sub.id}: recovered ${card.method}. ${DRY_RUN ? "would backfill" : "backfilling"}`);
if (!DRY_RUN) await backfillSub(sub.id, card);
fixed++;
}
console.log(`Done. ${fixed} backfilled, ${flagged} flagged ${DRY_RUN ? "(dry run)" : ""}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The check that picks which subscriptions to repair is the piece to test, since it decides what gets touched. It is pure, so the test passes in subscription objects.
from backfill_sub_token import needs_token_backfill
def sub(**over):
base = {"payment_method": "stripe", "status": "active", "meta_data": []}
base.update(over)
return base
def test_needs_backfill_when_no_customer():
assert needs_token_backfill(sub()) is True
def test_skip_when_customer_present():
s = sub(meta_data=[{"key": "_stripe_customer_id", "value": "cus_1"}])
assert needs_token_backfill(s) is False
def test_skip_cancelled():
assert needs_token_backfill(sub(status="cancelled")) is False
def test_skip_non_stripe():
assert needs_token_backfill(sub(payment_method="paypal")) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { needsTokenBackfill } from "./backfill-sub-token.js";
const sub = (over = {}) => ({ payment_method: "stripe", status: "active", meta_data: [], ...over });
test("needs backfill when no customer", () => {
assert.equal(needsTokenBackfill(sub()), true);
});
test("skip when customer present", () => {
assert.equal(needsTokenBackfill(sub({ meta_data: [{ key: "_stripe_customer_id", value: "cus_1" }] })), false);
});
test("skip cancelled", () => {
assert.equal(needsTokenBackfill(sub({ status: "cancelled" })), false);
});
test("skip non stripe", () => {
assert.equal(needsTokenBackfill(sub({ payment_method: "paypal" })), false);
});
Case studies
The retry that broke renewals
A customer's first card was declined, so they used a second card, which worked. They were subscribed and happy, but a month later the renewal failed, and so did every attempt after. The subscription had no card saved at all.
The reconciler read the customer and card from the first paid order and wrote them back onto the subscription. The next renewal charged the right card and went through.
The one that needed the customer
One subscription had paid its first order with a method that was never saved for reuse, so there was no card to recover on Stripe. Backfilling would not help.
Instead of guessing, the script flagged the subscription. The store sent that customer a link to add a card, and renewals resumed once they did.
After this runs on a schedule, a bumpy checkout no longer quietly breaks a subscription. Most lost cards are recovered from the first paid order without bothering the customer, and the few that cannot be are flagged clearly so you can ask for a new card instead of losing the renewal.
FAQ
Why does my WooCommerce subscription renewal fail with unable to process your payment?
The subscription has no saved Stripe customer or card. This often happens when the first card is declined at checkout and a retry succeeds, because the successful token is never written onto the subscription. Renewals then have nothing to charge.
Can I recover the card without asking the customer?
Often yes. The first order was paid, so Stripe still holds the customer and the payment method used. A script can read them from the paid PaymentIntent and write them back onto the subscription, which restores automatic renewals.
What if Stripe has no reusable card for the customer?
If the payment method was not saved for future use, there is nothing to recover. In that case the script flags the subscription so you can send the customer a link to add a card.
Related field notes
Citations
On the problem:
- WooCommerce Stripe plugin issue: subscription has no valid Stripe connection after a failed then successful checkout. github.com/woocommerce/woocommerce-gateway-stripe/issues/3644
- WooCommerce Stripe plugin issue: renewals fail because the saved token is missing. github.com/woocommerce/woocommerce-gateway-stripe/issues/1028
- WooCommerce docs: how subscription renewals charge a saved payment method. woocommerce.com/document/subscriptions
On the solution:
- Stripe API: retrieve a PaymentIntent and read its customer and payment method. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce Subscriptions REST API: list and update subscriptions. woocommerce.github.io/subscriptions-rest-api-docs
- Stripe docs: save a card for future payments. docs.stripe.com/payments/save-during-payment
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this get your renewals working again?
If this recovered the lost cards and saved a wave of failed renewals, you can buy me a coffee. It keeps these field notes free and growing.
Buy me a coffee on Ko-fi