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.

Python and Node.js Runs on a schedule or once Safe by default (dry run)
A brick wall with the words from the source painted on it
Photo by Taraqur Rahman on Unsplash
The short answer

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.

Renewal is due WooCommerce Subscriptions Customer default is a legacy Source src_... no off session confirmation Stripe declines SCA required Sub goes on hold
The card itself is fine. The default object attached to the customer is a Source, and a Source cannot clear SCA confirmation on an off session renewal.

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:

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

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.

Read customer default payment Is default a Source (src_...)? List attached PaymentMethods Fingerprint matches? yes no, report only Set as default invoice_settings
The script only promotes a PaymentMethod that already belongs to the customer and already matches the same card. Anyone with no match is left alone and reported separately.

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 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.

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 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.

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

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.

step3.py
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")
step3.js
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;
}
4

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.

decide.py
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"])
decide.js
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];
}
5

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.

apply.py
def promote_default(customer_id, payment_method_id):
    stripe.Customer.modify(
        customer_id,
        invoice_settings={"default_payment_method": payment_method_id},
    )
apply.js
async function promoteDefault(customerId, paymentMethodId) {
  await stripe.customers.update(customerId, {
    invoice_settings: { default_payment_method: paymentMethodId },
  });
}
6

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.

Run it safe

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_default_source.py
"""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-default-source.js
/**
 * 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.

test_decide.py
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"
decide.test.js
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

Gateway migration

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.

Regional SCA rollout

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.

What good looks like

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:

  1. 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
  2. Stripe docs: Strong Customer Authentication and why off session confirmations require a compatible PaymentMethod. docs.stripe.com/strong-customer-authentication
  3. WooCommerce Subscriptions docs: how automatic renewal payments are processed against the stored payment token. woocommerce.com/document/subscriptions/store-manager-guide

On the solution:

  1. Stripe API: update a customer's invoice_settings.default_payment_method. docs.stripe.com/api/customers/update
  2. Stripe API: list PaymentMethods attached to a customer. docs.stripe.com/api/payment_methods/list
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes