Reconciler Customers, cards, and tokens

A new card is not linked to the WooCommerce subscription

A customer updated their card because the old one was about to expire, and the change looked like it worked. But the next renewal charged the old card anyway and failed. The new card was saved to their Stripe customer, but the subscription still points at the old token. This guide repoints each subscription to the customer's current card so renewals use the right one.

Python and Node.js Runs on a schedule Uses the current default card
Pink envelope
Photo by Siora Photography on Unsplash
The short answer

The new card was added to the Stripe customer, but the subscription still stores the old card token, so renewals keep charging the old card. Read the customer's current default payment method from Stripe, compare it to the token saved on the subscription, and when they differ, write the current default onto the subscription. The full code is below.

The problem in plain words

A subscription renews by charging a saved card. That card is stored on the subscription as a token. When a customer updates their card, the new one is attached to their Stripe customer and set as the default, but the subscription is sometimes never updated to point at it. So the subscription keeps the old token, and every renewal charges the card the customer just replaced.

The customer thinks they fixed the problem. The renewal fails anyway. Support gets a confused ticket, and the subscription may be cancelled for a payment failure that was already solved.

New card added to Stripe customer Sub not updated still stores old token Renewal runs charges old card Fails old card
The customer fixed their card on Stripe. The subscription never got the memo, so it charges the old one.

Why it happens

The WooCommerce Stripe plugin has reported cases where the change payment flow adds the card to the customer but does not update the token stored on the subscription, so renewals keep using the old card. Editing the subscription meta by hand fixes it, which proves the automatic write is the broken step. The citations at the end link the threads.

The key insight

Stripe knows the current card. When a customer updates their card, it becomes the customer's default payment method on Stripe. So the fix is to read that default and make sure the subscription stores it. If the subscription points at something else, repoint it.

The fix, as a flow

We list active subscriptions, read the token each one stores, and read the customer's current default payment method from Stripe. When the two differ, we write the current default onto the subscription. Subscriptions that already match are skipped.

Active subs stored token Customer default from Stripe Differ? compare Repoint to default
Compare the stored token with the customer's current default, and repoint the subscription when they differ.

Build it step by step

1

Decide whether to repoint with a pure function

Keep the rule in one function. Repoint when the customer has a default payment method and it is different from the token the subscription stores. If there is no default, or it already matches, do nothing.

decide.py
def needs_repoint(stored_token, customer_default):
    if not customer_default:
        return False
    return stored_token != customer_default
decide.js
export function needsRepoint(storedToken, customerDefault) {
  if (!customerDefault) return false;
  return storedToken !== customerDefault;
}
2

Read the customer's current default card

The subscription stores the Stripe customer ID. Retrieve that customer and read the default payment method from the invoice settings. That is the card Stripe will use, and the one the subscription should point at.

step2.py
def customer_default(customer_id):
    if not customer_id:
        return None
    customer = stripe.Customer.retrieve(customer_id)
    return (customer.get("invoice_settings") or {}).get("default_payment_method")
step2.js
async function customerDefault(customerId) {
  if (!customerId) return null;
  const customer = await stripe.customers.retrieve(customerId);
  return (customer.invoice_settings || {}).default_payment_method || null;
}
3

Write the current card onto the subscription

When the stored token and the current default differ, write the current default onto the subscription as its card token, and add a note. The next renewal then charges the card the customer actually wants to use.

repoint.py
def repoint(sub_id, new_token):
    requests.put(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
                 json={"meta_data": [{"key": "_stripe_source_id", "value": new_token}]},
                 auth=AUTH, timeout=30).raise_for_status()
    add_sub_note(sub_id, f"Repointed the subscription to the current default card {new_token} "
                         f"so renewals stop charging the old one.")
repoint.js
async function repoint(subId, newToken) {
  await woo(`/subscriptions/${subId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: "_stripe_source_id", value: newToken }] }),
  });
  await addSubNote(subId, `Repointed the subscription to the current default card ${newToken} so renewals stop charging the old one.`);
}
Only follow the customer's own default

Repoint only to the default payment method on the subscription's own Stripe customer. Never move a card between customers. Start with DRY_RUN=true and read which subscriptions it would repoint.

The full code

The complete reconciler lists active subscriptions, compares each stored card token to the customer's current default, and repoints the ones that drifted. It skips anything that already matches, so it is safe to run on a schedule.

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

repoint_sub_card.py
"""Repoint WooCommerce subscriptions to the customer's current default Stripe card,
so renewals stop charging an old card the customer already replaced.
Run on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/woocommerce/new-card-not-linked-to-subscription/
"""
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("repoint_sub_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 needs_repoint(stored_token, customer_default):
    if not customer_default:
        return False
    return stored_token != customer_default


def get_meta(sub, key):
    for m in sub.get("meta_data", []):
        if m.get("key") == key:
            return m.get("value")
    return None


def active_subscriptions():
    page = 1
    while True:
        r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions",
                         params={"status": "active", "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 customer_default(customer_id):
    if not customer_id:
        return None
    customer = stripe.Customer.retrieve(customer_id)
    return (customer.get("invoice_settings") or {}).get("default_payment_method")


def repoint(sub_id, new_token):
    requests.put(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
                 json={"meta_data": [{"key": "_stripe_source_id", "value": new_token}]},
                 auth=AUTH, timeout=30).raise_for_status()
    requests.post(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
                  json={"note": f"Repointed the subscription to the current default card {new_token} "
                                f"so renewals stop charging the old one."},
                  auth=AUTH, timeout=30).raise_for_status()


def run():
    fixed = 0
    for sub in active_subscriptions():
        if not sub["payment_method"].startswith("stripe"):
            continue
        stored = get_meta(sub, "_stripe_source_id")
        default = customer_default(get_meta(sub, "_stripe_customer_id"))
        if not needs_repoint(stored, default):
            continue
        log.info("Subscription %s: %s -> %s. %s", sub["id"], stored, default, "dry run" if DRY_RUN else "repointing")
        if not DRY_RUN:
            repoint(sub["id"], default)
        fixed += 1
    log.info("Done. %d subscription(s) %s.", fixed, "to repoint" if DRY_RUN else "repointed")


if __name__ == "__main__":
    run()
repoint-sub-card.js
/**
 * Repoint WooCommerce subscriptions to the customer's current default Stripe card,
 * so renewals stop charging an old card the customer already replaced.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/new-card-not-linked-to-subscription/
 */
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";

export function needsRepoint(storedToken, customerDefault) {
  if (!customerDefault) return false;
  return storedToken !== customerDefault;
}

export function getMeta(sub, key) {
  const hit = (sub.meta_data || []).find((m) => m.key === key);
  return hit ? hit.value : null;
}

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 subs = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
    if (!subs.length) return;
    for (const sub of subs) yield sub;
    page++;
  }
}

async function customerDefault(customerId) {
  if (!customerId) return null;
  const customer = await stripe.customers.retrieve(customerId);
  return (customer.invoice_settings || {}).default_payment_method || null;
}

async function repoint(subId, newToken) {
  await woo(`/subscriptions/${subId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: "_stripe_source_id", value: newToken }] }),
  });
  await woo(`/subscriptions/${subId}/notes`, {
    method: "POST",
    body: JSON.stringify({ note: `Repointed the subscription to the current default card ${newToken} so renewals stop charging the old one.` }),
  });
}

async function run() {
  let fixed = 0;
  for await (const sub of activeSubscriptions()) {
    if (!sub.payment_method.startsWith("stripe")) continue;
    const stored = getMeta(sub, "_stripe_source_id");
    const def = await customerDefault(getMeta(sub, "_stripe_customer_id"));
    if (!needsRepoint(stored, def)) continue;
    console.log(`Subscription ${sub.id}: ${stored} -> ${def}. ${DRY_RUN ? "dry run" : "repointing"}`);
    if (!DRY_RUN) await repoint(sub.id, def);
    fixed++;
  }
  console.log(`Done. ${fixed} subscription(s) ${DRY_RUN ? "to repoint" : "repointed"}.`);
}

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

Add a test

The repoint rule decides which subscriptions get a new card token, so it is the piece to test. It is pure, so the test just passes in tokens.

test_needs_repoint.py
from repoint_sub_card import needs_repoint


def test_repoint_when_different():
    assert needs_repoint("pm_old", "pm_new") is True


def test_skip_when_same():
    assert needs_repoint("pm_same", "pm_same") is False


def test_skip_when_no_default():
    assert needs_repoint("pm_old", None) is False


def test_repoint_when_none_stored():
    assert needs_repoint(None, "pm_new") is True
needs-repoint.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { needsRepoint } from "./repoint-sub-card.js";

test("repoint when different", () => {
  assert.equal(needsRepoint("pm_old", "pm_new"), true);
});

test("skip when same", () => {
  assert.equal(needsRepoint("pm_same", "pm_same"), false);
});

test("skip when no default", () => {
  assert.equal(needsRepoint("pm_old", null), false);
});

test("repoint when none stored", () => {
  assert.equal(needsRepoint(null, "pm_new"), true);
});

Case studies

Expiring card

The update that did not stick

A customer replaced a card that was about to expire. The new card showed in their account, but the next renewal still tried the old one and failed. They had already done the right thing, and it still broke.

The reconciler read the customer's current default card and repointed the subscription to it. The following renewal charged the new card and went through.

Bulk drift

The month of quiet failures

A store found a cluster of renewal failures all sharing the same cause: cards updated on the customer but never linked to the subscription. Support had been re-entering cards one by one.

Running the reconciler across all active subscriptions repointed every drifted one in a single pass, and the renewal failure rate dropped back to normal.

What good looks like

After this runs on a schedule, a card update actually takes effect. Subscriptions follow the customer's current default card, renewals stop charging replaced cards, and support stops re-entering cards that were already fixed.

FAQ

Why does my subscription still charge the old card after the customer updated it?

The new card was added to the Stripe customer, but the subscription still stores the old card token in its meta. Renewals read that stored token, so they keep charging the old card until the subscription is repointed to the current one.

How do I repoint the subscription to the new card?

Read the customer's current default payment method from Stripe and compare it to the token saved on the subscription. When they differ, write the current default onto the subscription meta so the next renewal uses it.

Is this safe to run in bulk?

Yes. It only updates the stored card token to the customer's own current default on Stripe, and it skips subscriptions that already match. Start in dry run mode to review the changes.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: changed card not saved to the subscription, renewals use the old card. github.com/woocommerce/woocommerce-gateway-stripe/issues/281
  2. WooCommerce Stripe plugin issue: subscription keeps charging the old card after a change. github.com/woocommerce/woocommerce-gateway-stripe/issues/149
  3. WooCommerce docs: how subscriptions charge a saved payment method. woocommerce.com/document/subscriptions

On the solution:

  1. Stripe API: retrieve a customer and read invoice_settings.default_payment_method. docs.stripe.com/api/customers/retrieve
  2. Stripe docs: set the default payment method for a customer. docs.stripe.com/payments/save-and-reuse
  3. WooCommerce Subscriptions REST API: update a subscription. woocommerce.github.io/subscriptions-rest-api-docs

Stuck on a tricky one?

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

Contact me on LinkedIn

Did this fix your renewals?

If this got your subscriptions charging the right card again, 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