Repair Sources to PaymentMethods and SCA
Promote the default source to a real PaymentMethod
A subscription has been renewing fine for years, then one day it just stops. The card has not expired, the customer has not disputed anything, and nothing in the WooCommerce order looks wrong. The real problem is sitting quietly on the Stripe customer object, the thing everything else charges against is still a legacy Source, and Sources cannot be confirmed off session once Strong Customer Authentication applies. Here is why that happens and a small script that finds every customer stuck like this and promotes a proper PaymentMethod as the default.
Some Stripe customers still have a legacy Source object set as their default payment, usually because the card was saved years ago before PaymentMethods existed. A Source cannot be confirmed off session under SCA, so the next renewal fails even though the card is fine. Run a small Python or Node.js script that lists each customer's attached PaymentMethods, finds one that matches the old Source by card fingerprint, and sets it as invoice_settings.default_payment_method. Full code, tests, and a dry run guard are below.
The problem in plain words
Stripe has had two different ways to represent a saved card over the years. The old way was a Source object, created by the original Stripe gateway plugins for WooCommerce back before Strong Customer Authentication was a thing anyone had to think about. The new way is a PaymentMethod, which is what every off session, SCA aware charge needs today.
When a store migrated its integration or updated its Stripe gateway plugin, new cards started saving as PaymentMethods. But older customers, the ones who saved a card years ago and never touched checkout again, were left with their original Source still sitting as the customer's default. WooCommerce Subscriptions dutifully tries to charge that default every renewal, Stripe tries to confirm it off session, and a Source simply cannot go through that confirmation step. The renewal fails, and the failure reason rarely says "your default is the wrong object type," it just looks like a generic decline.
Why it happens
Stripe's own migration docs describe exactly this transition, and are clear that Sources should be replaced with PaymentMethods for any card that needs to be charged off session. A few common ways a store ends up with customers stuck on the old object:
- The card was saved years ago through an old WooCommerce Stripe gateway version that only spoke the Sources API, and the customer never returned to update their payment details.
- A newer PaymentMethod was created for a one time purchase, but nobody told Stripe to make it the customer's new default, so
invoice_settings.default_payment_methodstill points at the original Source. - A store migration or gateway switch created fresh PaymentMethods for active subscriptions, but skipped anyone who was not actively subscribed at the time of the migration.
- SCA enforcement in the customer's region tightened after the card was saved, so a Source that used to renew fine suddenly starts failing with no code change on the store's side at all.
This shows up in support tickets as "my card just stopped working" from a customer who has not touched their payment details in years, which is usually the first clue that the object type, not the card, is the problem.
The fix is almost never a new card. In most cases the customer already has a working PaymentMethod attached, either from a newer purchase or created alongside the old Source during a partial migration. The job is to find that PaymentMethod and promote it, not to ask the customer to re-enter anything.
The fix, as a flow
We do not touch checkout or the subscription itself. We add a script that walks the customers behind active subscriptions, checks whether each customer's default is still a Source, and if so looks for an attached PaymentMethod whose card fingerprint matches the Source's card fingerprint. When a match exists, we promote it to invoice_settings.default_payment_method, the same field WooCommerce Subscriptions reads before every renewal attempt.
Build it step by step
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 at least read access to subscriptions. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
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
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
List the WooCommerce customers behind active subscriptions
Pull active and on hold subscriptions from the WooCommerce REST API, and collect the Stripe customer ID off each one. WooCommerce Subscriptions with the Stripe gateway stores this in order meta as _stripe_customer_id, so it travels with every renewal order tied to the subscription.
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 meta_value(obj, key):
for m in obj.get("meta_data") or []:
if m.get("key") == key:
return m.get("value")
return None
def active_subscription_customers():
page = 1
seen = set()
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:
customer_id = meta_value(sub, "_stripe_customer_id")
if customer_id and customer_id not in seen:
seen.add(customer_id)
yield customer_id
page += 1
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");
function metaValue(obj, key) {
return (obj.meta_data || []).find((m) => m.key === key)?.value ?? null;
}
async function* activeSubscriptionCustomers() {
const seen = new Set();
let page = 1;
while (true) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/subscriptions?status=active,on-hold&per_page=50&page=${page}`,
{ headers: { Authorization: AUTH } }
);
if (!res.ok) throw new Error(`Woo subscriptions returned ${res.status}`);
const batch = await res.json();
if (!batch.length) return;
for (const sub of batch) {
const customerId = metaValue(sub, "_stripe_customer_id");
if (customerId && !seen.has(customerId)) {
seen.add(customerId);
yield customerId;
}
}
page++;
}
}
Read the customer's default and their attached PaymentMethods
Retrieve the customer from Stripe, expand the default source, and separately list attached card PaymentMethods. A legacy default shows up as an object whose id starts with src_, while a PaymentMethod id starts with pm_. Matching them by card fingerprint means the same physical card, not just the same last four digits.
import stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def load_customer_state(customer_id):
customer = stripe.Customer.retrieve(customer_id)
methods = stripe.PaymentMethod.list(customer=customer_id, type="card")
return customer, list(methods.auto_paging_iter())
def source_fingerprint(customer, source_id):
if not source_id or not source_id.startswith("src_"):
return None
source = stripe.Source.retrieve(source_id)
return (source.get("card") or {}).get("fingerprint")
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function loadCustomerState(customerId) {
const customer = await stripe.customers.retrieve(customerId);
const methods = await stripe.paymentMethods.list({ customer: customerId, type: "card" });
return { customer, methods: methods.data };
}
async function sourceFingerprint(sourceId) {
if (!sourceId || !sourceId.startsWith("src_")) return null;
const source = await stripe.sources.retrieve(sourceId);
return source.card?.fingerprint || null;
}
Decide, with one pure function
Keep the decision in its own function that takes the customer's current default id, its card fingerprint, and the list of attached PaymentMethods, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If the default is already a PaymentMethod, skip it. If it is a Source with no matching PaymentMethod, report it for manual follow up. Otherwise, promote the match.
def decide(default_id, default_fingerprint, payment_methods):
if not default_id:
return ("no_default", "customer has no default payment set")
if default_id.startswith("pm_"):
return ("skip", "default is already a PaymentMethod")
if not default_id.startswith("src_"):
return ("skip", "default is neither a Source nor a PaymentMethod")
matches = [
pm for pm in payment_methods
if pm.get("card", {}).get("fingerprint") == default_fingerprint
and default_fingerprint is not None
]
if not matches:
return ("no_match", "default is a legacy Source with no matching PaymentMethod")
# Prefer the most recently created match if more than one exists.
best = max(matches, key=lambda pm: pm.get("created", 0))
return ("promote", best["id"])
export function decide(defaultId, defaultFingerprint, paymentMethods) {
if (!defaultId) return ["no_default", "customer has no default payment set"];
if (defaultId.startsWith("pm_")) return ["skip", "default is already a PaymentMethod"];
if (!defaultId.startsWith("src_")) {
return ["skip", "default is neither a Source nor a PaymentMethod"];
}
const matches = paymentMethods.filter(
(pm) => defaultFingerprint != null && pm.card?.fingerprint === defaultFingerprint
);
if (matches.length === 0) {
return ["no_match", "default is a legacy Source with no matching PaymentMethod"];
}
// Prefer the most recently created match if more than one exists.
const best = matches.reduce((a, b) => ((b.created || 0) > (a.created || 0) ? b : a));
return ["promote", best.id];
}
Promote the PaymentMethod and confirm it took
When the action is promote, write the PaymentMethod id to invoice_settings.default_payment_method on the customer. This is the exact field WooCommerce Subscriptions and Stripe's own automatic charges read before an off session renewal. Nothing about the subscription record itself changes. It is purely a customer level setting.
def promote_default(customer_id, payment_method_id):
stripe.Customer.modify(
customer_id,
invoice_settings={"default_payment_method": payment_method_id},
)
async function promoteDefault(customerId, paymentMethodId) {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would promote, and separately reports every customer it could not fix automatically. Read both lists, trust them, then switch it off to let it write. This script is safe to run once or on a schedule, since it never touches a customer whose default is already correct.
Always start with DRY_RUN=true. Promoting a default payment method changes what Stripe charges for every future renewal on that customer, so you want to see the plan before it acts. Once the report looks right, turn it off.
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 is safe to run again and again because it never touches a customer whose default is already a PaymentMethod.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Promote a customer's default Source to a matching PaymentMethod.
A customer whose default payment is still a legacy Source cannot be charged
off session under SCA. This walks customers behind active or on hold
subscriptions, finds anyone whose Stripe default is a Source, looks for an
attached PaymentMethod with a matching card fingerprint, and promotes it to
invoice_settings.default_payment_method. 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("promote_default_source")
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 meta_value(obj, key):
for m in obj.get("meta_data") or []:
if m.get("key") == key:
return m.get("value")
return None
def active_subscription_customers():
page = 1
seen = set()
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:
customer_id = meta_value(sub, "_stripe_customer_id")
if customer_id and customer_id not in seen:
seen.add(customer_id)
yield customer_id
page += 1
def load_customer_state(customer_id):
customer = stripe.Customer.retrieve(customer_id)
methods = stripe.PaymentMethod.list(customer=customer_id, type="card")
return customer, list(methods.auto_paging_iter())
def source_fingerprint(source_id):
if not source_id or not source_id.startswith("src_"):
return None
source = stripe.Source.retrieve(source_id)
return (source.get("card") or {}).get("fingerprint")
def decide(default_id, default_fingerprint, payment_methods):
if not default_id:
return ("no_default", "customer has no default payment set")
if default_id.startswith("pm_"):
return ("skip", "default is already a PaymentMethod")
if not default_id.startswith("src_"):
return ("skip", "default is neither a Source nor a PaymentMethod")
matches = [
pm for pm in payment_methods
if pm.get("card", {}).get("fingerprint") == default_fingerprint
and default_fingerprint is not None
]
if not matches:
return ("no_match", "default is a legacy Source with no matching PaymentMethod")
best = max(matches, key=lambda pm: pm.get("created", 0))
return ("promote", best["id"])
def promote_default(customer_id, payment_method_id):
stripe.Customer.modify(
customer_id,
invoice_settings={"default_payment_method": payment_method_id},
)
def run():
promoted = 0
unresolved = 0
for customer_id in active_subscription_customers():
customer, methods = load_customer_state(customer_id)
default_id = (customer.get("invoice_settings") or {}).get("default_payment_method") \
or customer.get("default_source")
fingerprint = source_fingerprint(default_id)
action, payload = decide(default_id, fingerprint, methods)
if action in ("skip", "no_default"):
continue
if action == "no_match":
log.warning("Customer %s: %s", customer_id, payload)
unresolved += 1
continue
log.info(
"Customer %s: promoting %s over %s. %s",
customer_id, payload, default_id, "would promote" if DRY_RUN else "promoting",
)
if not DRY_RUN:
promote_default(customer_id, payload)
promoted += 1
log.info(
"Done. %d customer(s) %s, %d unresolved (no matching PaymentMethod).",
promoted, "to promote" if DRY_RUN else "promoted", unresolved,
)
if __name__ == "__main__":
run()
/**
* Promote a customer's default Source to a matching PaymentMethod.
*
* A customer whose default payment is still a legacy Source cannot be
* charged off session under SCA. This walks customers behind active or on
* hold subscriptions, finds anyone whose Stripe default is a Source, looks
* for an attached PaymentMethod with a matching card fingerprint, and
* promotes it to invoice_settings.default_payment_method. 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";
function metaValue(obj, key) {
return (obj.meta_data || []).find((m) => m.key === key)?.value ?? null;
}
async function* activeSubscriptionCustomers() {
const seen = new Set();
let page = 1;
while (true) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/subscriptions?status=active,on-hold&per_page=50&page=${page}`,
{ headers: { Authorization: AUTH } }
);
if (!res.ok) throw new Error(`Woo subscriptions returned ${res.status}`);
const batch = await res.json();
if (!batch.length) return;
for (const sub of batch) {
const customerId = metaValue(sub, "_stripe_customer_id");
if (customerId && !seen.has(customerId)) {
seen.add(customerId);
yield customerId;
}
}
page++;
}
}
async function loadCustomerState(customerId) {
const customer = await stripe.customers.retrieve(customerId);
const methods = await stripe.paymentMethods.list({ customer: customerId, type: "card" });
return { customer, methods: methods.data };
}
async function sourceFingerprint(sourceId) {
if (!sourceId || !sourceId.startsWith("src_")) return null;
const source = await stripe.sources.retrieve(sourceId);
return source.card?.fingerprint || null;
}
export function decide(defaultId, defaultFingerprint, paymentMethods) {
if (!defaultId) return ["no_default", "customer has no default payment set"];
if (defaultId.startsWith("pm_")) return ["skip", "default is already a PaymentMethod"];
if (!defaultId.startsWith("src_")) {
return ["skip", "default is neither a Source nor a PaymentMethod"];
}
const matches = paymentMethods.filter(
(pm) => defaultFingerprint != null && pm.card?.fingerprint === defaultFingerprint
);
if (matches.length === 0) {
return ["no_match", "default is a legacy Source with no matching PaymentMethod"];
}
const best = matches.reduce((a, b) => ((b.created || 0) > (a.created || 0) ? b : a));
return ["promote", best.id];
}
async function promoteDefault(customerId, paymentMethodId) {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
}
export async function run() {
let promoted = 0;
let unresolved = 0;
for await (const customerId of activeSubscriptionCustomers()) {
const { customer, methods } = await loadCustomerState(customerId);
const defaultId = customer.invoice_settings?.default_payment_method || customer.default_source;
const fingerprint = await sourceFingerprint(defaultId);
const [action, payload] = decide(defaultId, fingerprint, methods);
if (action === "skip" || action === "no_default") continue;
if (action === "no_match") {
console.warn(`Customer ${customerId}: ${payload}`);
unresolved++;
continue;
}
console.log(
`Customer ${customerId}: promoting ${payload} over ${defaultId}. ${DRY_RUN ? "would promote" : "promoting"}`
);
if (!DRY_RUN) await promoteDefault(customerId, payload);
promoted++;
}
console.log(
`Done. ${promoted} customer(s) ${DRY_RUN ? "to promote" : "promoted"}, ${unresolved} unresolved (no matching PaymentMethod).`
);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The decision rule is the part most worth testing, because it decides whose renewal payment source changes. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from promote_default_source import decide
def pm(id_="pm_1", fingerprint="fp_abc", created=100):
return {"id": id_, "card": {"fingerprint": fingerprint}, "created": created}
def test_promote_when_source_matches_a_payment_method():
action, payload = decide("src_1", "fp_abc", [pm()])
assert action == "promote"
assert payload == "pm_1"
def test_skip_when_already_a_payment_method():
action, _ = decide("pm_1", "fp_abc", [pm()])
assert action == "skip"
def test_no_match_when_fingerprint_differs():
action, _ = decide("src_1", "fp_xyz", [pm(fingerprint="fp_abc")])
assert action == "no_match"
def test_no_default_when_customer_has_nothing_set():
action, _ = decide(None, None, [])
assert action == "no_default"
def test_promote_prefers_most_recently_created_match():
older = pm(id_="pm_old", created=100)
newer = pm(id_="pm_new", created=200)
action, payload = decide("src_1", "fp_abc", [older, newer])
assert action == "promote"
assert payload == "pm_new"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./promote-default-source.js";
const pm = (over = {}) => ({
id: "pm_1",
card: { fingerprint: "fp_abc" },
created: 100,
...over,
});
test("promote when source matches a payment method", () => {
const [action, payload] = decide("src_1", "fp_abc", [pm()]);
assert.equal(action, "promote");
assert.equal(payload, "pm_1");
});
test("skip when already a payment method", () => {
const [action] = decide("pm_1", "fp_abc", [pm()]);
assert.equal(action, "skip");
});
test("no_match when fingerprint differs", () => {
const [action] = decide("src_1", "fp_xyz", [pm({ card: { fingerprint: "fp_abc" } })]);
assert.equal(action, "no_match");
});
test("no_default when customer has nothing set", () => {
const [action] = decide(null, null, []);
assert.equal(action, "no_default");
});
test("promote prefers most recently created match", () => {
const older = pm({ id: "pm_old", created: 100 });
const newer = pm({ id: "pm_new", created: 200 });
const [action, payload] = decide("src_1", "fp_abc", [older, newer]);
assert.equal(action, "promote");
assert.equal(payload, "pm_new");
});
Case studies
The store that switched plugins and left half its customers behind
A shop moved from an older Stripe gateway plugin to a newer one that only worked with PaymentMethods. New checkouts were fine. But around six hundred existing subscribers still had a Source as their Stripe default, and their renewals began failing at a slow, steady trickle over the following weeks.
Running the script in dry run showed that most of those customers already had a matching PaymentMethod on file from a prior top up purchase. A single real run promoted over five hundred of them in minutes, and the rest were reported separately for a one time re-entry email.
Renewals that worked for years, until European enforcement caught up
A subscription business with a large base of European customers saw a wave of failed renewals right after regional SCA enforcement tightened. Nothing on the store had changed. The common thread across every failing customer was the same, a Source still set as their default from years earlier.
The script found the matching PaymentMethod for the majority of them, since most had made at least one newer purchase that saved a proper PaymentMethod, and promoted it as the new default without asking anyone to touch their card again.
After this runs, renewals for affected customers go back to charging off session the way they always did, this time against an object Stripe can actually confirm under SCA. Anyone left in the unresolved list did not lose their subscription, they just need a fresh SetupIntent, which is a normal, one time step rather than a mystery decline.
FAQ
Why does a saved card fail to renew even though it worked before?
The customer's default in Stripe is still a legacy Source, not a PaymentMethod. Sources cannot be confirmed off session under SCA rules, so Stripe declines the renewal even though the card itself is fine. Promoting a matching PaymentMethod to invoice_settings.default_payment_method fixes it.
Is it safe to change a customer's default payment method with a script?
Yes, when the script only promotes a PaymentMethod that is already attached to the same customer and skips any customer whose default is already a PaymentMethod. Start in dry run mode to review the list of customers before it writes anything.
What happens if the customer has no matching PaymentMethod yet?
The script leaves that customer alone and reports it separately, since there is nothing safe to promote. Those customers need a fresh SetupIntent so the card is saved again as a PaymentMethod, which is a separate, one time step.
Related field notes
Citations
On the problem:
- Stripe docs: migrating from Sources to PaymentMethods and why Sources cannot be confirmed off session under SCA. docs.stripe.com/payments/payment-methods/migrating-sources-to-payment-methods
- Stripe docs: Strong Customer Authentication and why off session confirmations require a compatible PaymentMethod. docs.stripe.com/strong-customer-authentication
- WooCommerce Subscriptions docs: how automatic renewal payments are processed against the stored payment token. woocommerce.com/document/subscriptions/store-manager-guide
On the solution:
- Stripe API: update a customer's invoice_settings.default_payment_method. docs.stripe.com/api/customers/update
- Stripe API: list PaymentMethods attached to a customer. docs.stripe.com/api/payment_methods/list
- WooCommerce REST API: list subscriptions and read order and subscription meta data. 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.
Did this fix your failing renewals?
If this saved you a pile of support tickets or a chargeback, 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