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.

Python and Node.js Runs on a schedule Recovers the real card
Person holding black android smartphone
Photo by CardMapr.nl on Unsplash
The short answer

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.

Retry succeeds first order paid token not saved Sub has no card no customer id Renewal runs nothing to charge Fails every time
The first payment worked, but the card was never linked to the subscription, so renewals have nothing to charge.

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 key insight

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.

Subs with no card no customer id Read paid order its PaymentIntent Customer + card from Stripe Backfill sub renewals work
Recover the customer and card from the paid order, and put them back on the subscription.

Build it step by step

1

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.

detect.py
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")
detect.js
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");
}
2

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.

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

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.

backfill.py
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.")
backfill.js
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.`);
}
Recover, do not guess

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.

backfill_sub_token.py
"""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()
backfill-sub-token.js
/**
 * 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.

test_needs_token.py
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
needs-token.test.js
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

Bumpy checkout

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.

No saved card

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.

What good looks like

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:

  1. WooCommerce Stripe plugin issue: subscription has no valid Stripe connection after a failed then successful checkout. github.com/woocommerce/woocommerce-gateway-stripe/issues/3644
  2. WooCommerce Stripe plugin issue: renewals fail because the saved token is missing. github.com/woocommerce/woocommerce-gateway-stripe/issues/1028
  3. WooCommerce docs: how subscription renewals charge a saved payment method. woocommerce.com/document/subscriptions

On the solution:

  1. Stripe API: retrieve a PaymentIntent and read its customer and payment method. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce Subscriptions REST API: list and update subscriptions. woocommerce.github.io/subscriptions-rest-api-docs
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes