Repair Bulk subscription operations

Push a card change to Stripe

A customer changes their card in WooCommerce, the new card is charged, the order goes through without a hitch. Everyone assumes the job is done. Then the next renewal in Stripe reaches for a card that no longer works, because Stripe was never told the card changed. Here is why the Stripe customer record falls behind and a small script that pushes the new card to Stripe as the default.

Python and Node.js Runs on a schedule Safe by default (dry run)
White and blue magnetic card
Photo by Avery Evans on Unsplash
The short answer

WooCommerce happily charges a new card on a single order, but nothing in that flow tells Stripe to make that card the default for future charges. Stripe's invoice_settings.default_payment_method on the customer stays pointed at the old card until something updates it directly. Run a small Python or Node.js script on a schedule that reads the PaymentIntent from each paid order's _stripe_intent_id meta (or transaction_id), compares its payment method to the customer's current Stripe default, and pushes the new one across when they differ. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce and Stripe agree about the card at the moment of checkout, and disagree quietly right after. When a customer pays with a new card, that card is attached to the PaymentIntent and the charge succeeds. WooCommerce is satisfied. The order is paid. Nothing looks wrong.

But the Stripe customer object, the thing Stripe actually looks at for the next automatic renewal, keeps a separate field called the default payment method. Paying once with a new card does not automatically update that field. So the order that just succeeded and the subscription that renews next month can be pointed at two different cards, and only one of them still works.

Customer pays with a new card Order paid PaymentIntent succeeded default never set Stripe customer default: old card Next renewal declined
The order pays with the new card and succeeds. Stripe's stored default payment method is untouched, so the next automatic charge still reaches for the old card.

Why it happens

Stripe's own API design keeps "the card used for this one charge" and "the card to use next time" as two separate ideas. Charging a PaymentIntent with a payment method does not implicitly set that payment method as the customer's default. A few common ways this gap shows up on WooCommerce stores:

Stripe's own guidance on payment methods is explicit that setting invoice_settings.default_payment_method is a separate call from confirming a PaymentIntent, and that Billing subscriptions fall back to that customer level default when a subscription has none of its own. See the citations at the end for the exact reference.

The key insight

WooCommerce already knows the truth. It has the PaymentIntent from the order that just succeeded, and that PaymentIntent carries the payment method the customer actually used. The fix is not clever, it is a small chore Stripe expects someone to do: read that payment method off the paid order and explicitly push it onto the customer as the new default.

The fix, as a flow

We do not touch checkout. We add a job that runs on a schedule, looks at orders that were paid recently, and for each one reads the PaymentIntent's payment method. If the amount checks out and that payment method is not already the customer's Stripe default, the job attaches it if needed and sets it as the default. It leaves an order note behind so the change is visible.

Scheduled job every few hours List paid orders recent Processing, Completed Load intent and Stripe customer Already the default? no yes, skip Set as default attach + note on order
The job only pushes a card when the order it came from is genuinely paid and the card is not already the Stripe default. Everything else is left alone.

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 read and write access to orders. 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 LOOKBACK_DAYS="7"
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 LOOKBACK_DAYS="7"
export DRY_RUN="true"   // start safe, change to false to write
2

Find the payment method from the paid order

Read the WooCommerce order's saved PaymentIntent ID from the _stripe_intent_id meta field, falling back to transaction_id when it looks like a PaymentIntent ID. Retrieve that PaymentIntent from Stripe and pull its payment_method, the card the customer actually used.

step2.py
import stripe

def intent_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step2.js
export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
3

Load the Stripe customer and its current default

The customer ID is usually saved on the order too, as _stripe_customer_id. Retrieve the customer and read invoice_settings.default_payment_method, which is what Stripe Billing and off-session charges reach for when no other payment method is specified.

step3.py
def customer_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_customer_id" and meta.get("value"):
            return meta["value"]
    return None

def get_customer(customer_id):
    if not customer_id:
        return None
    try:
        return stripe.Customer.retrieve(customer_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
export function customerIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_customer_id" && meta.value) return meta.value;
  }
  return null;
}

async function getCustomer(customerId) {
  if (!customerId) return null;
  try {
    return await stripe.customers.retrieve(customerId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order, the intent, and the customer, and returns an action. Money math stays in minor units (cents) so a rounding difference in dollars never causes a false mismatch. The rule: skip anything not paid or not succeeded, treat a missing intent, payment method, or customer as an orphan to log, skip a mismatched amount to be safe, skip when Stripe is already in sync, and only push when the card genuinely differs.

decide.py
PAID_STATUSES = {"processing", "completed"}

def order_amount_minor(order):
    return round(float(order["total"]) * 100)

def decide(order, intent, customer):
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order not in a paid state")
    if intent is None:
        return ("orphan", "no PaymentIntent saved on this order")
    if intent.get("status") != "succeeded":
        return ("skip", "intent not succeeded")
    if not intent.get("payment_method"):
        return ("orphan", "intent has no payment_method attached")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("mismatch", "amount does not match the order, skipping to be safe")
    if customer is None:
        return ("orphan", "no Stripe customer found for this order")
    current_default = (customer.get("invoice_settings") or {}).get("default_payment_method")
    if current_default == intent["payment_method"]:
        return ("already-synced", "Stripe default payment method already matches")
    return ("push", "order paid with a card Stripe does not have as the default yet")
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent, customer) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
  if (!intent) return ["orphan", "no PaymentIntent saved on this order"];
  if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
  if (!intent.payment_method) return ["orphan", "intent has no payment_method attached"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["mismatch", "amount does not match the order, skipping to be safe"];
  }
  if (!customer) return ["orphan", "no Stripe customer found for this order"];
  const currentDefault = (customer.invoice_settings || {}).default_payment_method;
  if (currentDefault === intent.payment_method) {
    return ["already-synced", "Stripe default payment method already matches"];
  }
  return ["push", "order paid with a card Stripe does not have as the default yet"];
}
5

Push the card and leave a note

When the action is push, attach the payment method to the customer if it is not attached already (attaching an already attached one is a harmless no-op) and set it as the invoice default. Then add an order note so a shop manager can see the change and why it happened.

apply.py
def push_default(customer_id, payment_method_id):
    try:
        stripe.PaymentMethod.attach(payment_method_id, customer=customer_id)
    except stripe.error.InvalidRequestError as err:
        if "already been attached" not in str(err):
            raise
    stripe.Customer.modify(
        customer_id,
        invoice_settings={"default_payment_method": payment_method_id},
    )

def note_order(order_id, payment_method_id):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": f"Pushed the new card to Stripe as the default payment method "
                      f"({payment_method_id}). Future renewals will use this card."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function pushDefault(customerId, paymentMethodId) {
  try {
    await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId });
  } catch (err) {
    if (!String(err.message || "").includes("already been attached")) throw err;
  }
  await stripe.customers.update(customerId, {
    invoice_settings: { default_payment_method: paymentMethodId },
  });
}

async function noteOrder(orderId, paymentMethodId) {
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Pushed the new card to Stripe as the default payment method ` +
            `(${paymentMethodId}). Future renewals will use this card.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs leave DRY_RUN on so the script only reports what it would push. Read the output, trust it, then switch it off to let it write. This job does not need to run often, since cards do not change every minute. Every few hours on a schedule is plenty.

Run it safe

Always start with DRY_RUN=true. Changing a customer's default payment method affects every future renewal for that customer, so you want to see the plan before it acts.

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 skips any order whose card is already the Stripe default.

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

push_card_to_stripe.py
"""Push a WooCommerce card change to the Stripe customer's default payment method.

A shopper updates their card on a WooCommerce order (or through My Account, Change
payment method) and the new card is charged just fine on that one order. But the
Stripe customer record is never told the card changed, so `invoice_settings.
default_payment_method` still points at the old card. The next Stripe Billing renewal,
or the next off-session charge, reaches for the old card and fails.

This walks recent paid orders, reads the PaymentIntent saved on each one, and pushes
its payment method onto the Stripe customer as the new default whenever it differs
from what Stripe already has on file. Safe to run again and again. Dry run by default.
"""
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("push_card_to_stripe")

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"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def order_amount_minor(order):
    return round(float(order["total"]) * 100)


def decide(order, intent, customer):
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order not in a paid state")
    if intent is None:
        return ("orphan", "no PaymentIntent saved on this order")
    if intent.get("status") != "succeeded":
        return ("skip", "intent not succeeded")
    if not intent.get("payment_method"):
        return ("orphan", "intent has no payment_method attached")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("mismatch", "amount does not match the order, skipping to be safe")
    if customer is None:
        return ("orphan", "no Stripe customer found for this order")
    current_default = (customer.get("invoice_settings") or {}).get("default_payment_method")
    if current_default == intent["payment_method"]:
        return ("already-synced", "Stripe default payment method already matches")
    return ("push", "order paid with a card Stripe does not have as the default yet")


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def get_customer(customer_id):
    if not customer_id:
        return None
    try:
        return stripe.Customer.retrieve(customer_id)
    except stripe.error.InvalidRequestError:
        return None


def customer_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_customer_id" and meta.get("value"):
            return meta["value"]
    return None


def paid_orders():
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def push_default(customer_id, payment_method_id):
    try:
        stripe.PaymentMethod.attach(payment_method_id, customer=customer_id)
    except stripe.error.InvalidRequestError as err:
        if "already been attached" not in str(err):
            raise
    stripe.Customer.modify(
        customer_id,
        invoice_settings={"default_payment_method": payment_method_id},
    )


def note_order(order_id, payment_method_id):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": f"Pushed the new card to Stripe as the default payment method "
                      f"({payment_method_id}). Future renewals will use this card."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    pushed = 0
    for order in paid_orders():
        intent = get_intent(intent_id_of(order))
        customer_id = customer_id_of(order)
        customer = get_customer(customer_id)
        action, reason = decide(order, intent, customer)
        if action in ("skip", "already-synced"):
            continue
        if action == "orphan":
            log.warning("Order %s: %s", order["id"], reason)
            continue
        if action == "mismatch":
            log.warning("Order %s: %s", order["id"], reason)
            continue
        payment_method_id = intent["payment_method"]
        log.info("Order %s: %s. %s", order["id"], reason, "would push" if DRY_RUN else "pushing")
        if not DRY_RUN:
            push_default(customer_id, payment_method_id)
            note_order(order["id"], payment_method_id)
        pushed += 1
    log.info("Done. %d order(s) %s.", pushed, "to push" if DRY_RUN else "pushed")


if __name__ == "__main__":
    run()
push-card-to-stripe.js
/**
 * Push a WooCommerce card change to the Stripe customer's default payment method.
 *
 * A shopper updates their card on a WooCommerce order (or through My Account, Change
 * payment method) and the new card is charged just fine on that one order. But the
 * Stripe customer record is never told the card changed, so `invoice_settings.
 * default_payment_method` still points at the old card. The next Stripe Billing
 * renewal, or the next off-session charge, reaches for the old card and fails.
 *
 * This walks recent paid orders, reads the PaymentIntent saved on each one, and
 * pushes its payment method onto the Stripe customer as the new default whenever it
 * differs from what Stripe already has on file. Safe to run again and again. Dry
 * run by default.
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function customerIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_customer_id" && meta.value) return meta.value;
  }
  return null;
}

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent, customer) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
  if (!intent) return ["orphan", "no PaymentIntent saved on this order"];
  if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
  if (!intent.payment_method) return ["orphan", "intent has no payment_method attached"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["mismatch", "amount does not match the order, skipping to be safe"];
  }
  if (!customer) return ["orphan", "no Stripe customer found for this order"];
  const currentDefault = (customer.invoice_settings || {}).default_payment_method;
  if (currentDefault === intent.payment_method) {
    return ["already-synced", "Stripe default payment method already matches"];
  }
  return ["push", "order paid with a card Stripe does not have as the default yet"];
}

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 getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function getCustomer(customerId) {
  if (!customerId) return null;
  try {
    return await stripe.customers.retrieve(customerId);
  } catch {
    return null;
  }
}

async function* paidOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function pushDefault(customerId, paymentMethodId) {
  try {
    await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId });
  } catch (err) {
    if (!String(err.message || "").includes("already been attached")) throw err;
  }
  await stripe.customers.update(customerId, {
    invoice_settings: { default_payment_method: paymentMethodId },
  });
}

async function noteOrder(orderId, paymentMethodId) {
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Pushed the new card to Stripe as the default payment method ` +
            `(${paymentMethodId}). Future renewals will use this card.`,
    }),
  });
}

export async function run() {
  let pushed = 0;
  for await (const order of paidOrders()) {
    const intent = await getIntent(intentIdOf(order));
    const customerId = customerIdOf(order);
    const customer = await getCustomer(customerId);
    const [action, reason] = decide(order, intent, customer);
    if (action === "skip" || action === "already-synced") continue;
    if (action === "orphan" || action === "mismatch") {
      console.warn(`Order ${order.id}: ${reason}`);
      continue;
    }
    const paymentMethodId = intent.payment_method;
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would push" : "pushing"}`);
    if (!DRY_RUN) {
      await pushDefault(customerId, paymentMethodId);
      await noteOrder(order.id, paymentMethodId);
    }
    pushed++;
  }
  console.log(`Done. ${pushed} order(s) ${DRY_RUN ? "to push" : "pushed"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  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 Stripe customer gets rewritten. 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_push_card_decide.py
from push_card_to_stripe import decide, intent_id_of, customer_id_of


def intent(**over):
    base = {"status": "succeeded", "amount_received": 5000, "payment_method": "pm_new"}
    base.update(over)
    return base


def customer(default_pm):
    return {"invoice_settings": {"default_payment_method": default_pm}}


def order(**over):
    base = {"status": "processing", "total": "50.00"}
    base.update(over)
    return base


def test_push_when_stripe_default_is_the_old_card():
    assert decide(order(), intent(), customer("pm_old"))[0] == "push"


def test_already_synced_when_stripe_default_matches():
    assert decide(order(), intent(), customer("pm_new"))[0] == "already-synced"


def test_skip_when_order_not_paid():
    assert decide(order(status="pending"), intent(), customer("pm_old"))[0] == "skip"


def test_orphan_when_no_intent():
    assert decide(order(), None, customer("pm_old"))[0] == "orphan"


def test_orphan_when_no_customer():
    assert decide(order(), intent(), None)[0] == "orphan"


def test_mismatch_when_amount_differs():
    assert decide(order(total="80.00"), intent(), customer("pm_old"))[0] == "mismatch"
push-card-decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, customerIdOf } from "./push-card-to-stripe.js";

const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, payment_method: "pm_new", ...over });
const customer = (defaultPm) => ({ invoice_settings: { default_payment_method: defaultPm } });
const order = (over = {}) => ({ status: "processing", total: "50.00", ...over });

test("push when Stripe default is the old card", () => {
  assert.equal(decide(order(), intent(), customer("pm_old"))[0], "push");
});

test("already-synced when Stripe default matches", () => {
  assert.equal(decide(order(), intent(), customer("pm_new"))[0], "already-synced");
});

test("skip when order not paid", () => {
  assert.equal(decide(order({ status: "pending" }), intent(), customer("pm_old"))[0], "skip");
});

test("orphan when no intent", () => {
  assert.equal(decide(order(), null, customer("pm_old"))[0], "orphan");
});

test("orphan when no customer", () => {
  assert.equal(decide(order(), intent(), null)[0], "orphan");
});

test("mismatch when amount differs", () => {
  assert.equal(decide(order({ total: "80.00" }), intent(), customer("pm_old"))[0], "mismatch");
});

Case studies

Expired card

The subscriber who paid twice and still got declined

A subscriber's card expired mid-cycle. They updated it through My Account, and the store even let them pay an overdue renewal manually with the new card right there on the spot. The order went through. Everyone was relieved.

The following month, Stripe Billing tried the subscription renewal against the old, expired card anyway, because the customer's Stripe default had never changed. The script caught it on its next scheduled run, well before the subscriber noticed anything was wrong again.

Card migration

A processor switch that forgot the defaults

A store moved a batch of saved cards into Stripe from a legacy processor. Every card was attached to the right customer, but none of them were set as anyone's invoice default, since the import script only handled attaching, not defaulting.

The next batch of renewals all failed at once with no default payment method to fall back to. Running this script in dry run mode against the imported customers' most recent paid orders produced a clean list of exactly who needed a default set, and a normal run fixed all of them in one pass.

What good looks like

After this runs on a schedule, a card change in WooCommerce becomes a card change everywhere, not just on the one order that used it. Renewals keep working on the card the customer actually intends to use, and support stops getting "why did my subscription fail, I already updated my card" tickets.

FAQ

Why does Stripe keep charging the old card after the customer changed it in WooCommerce?

WooCommerce charges the new card just fine on the order that used it, but nothing tells the Stripe customer record that the card changed. Stripe keeps invoice_settings.default_payment_method pointed at the old card until something explicitly updates it. A script that reads the new card off the latest paid order and pushes it to Stripe as the default fixes it.

Is it safe to change a customer's default payment method with a script?

Yes, when the script only acts on orders that are paid, whose PaymentIntent succeeded with the right amount, and whose card is not already the Stripe default. Start in dry run mode to review the list before it writes.

Does this also update the payment method on a Stripe Billing subscription?

Setting the customer's default payment method is enough for most stores, since Stripe Billing falls back to it when a subscription has no payment method of its own. If a subscription has its own default_payment_method set, that value wins, so stores running Stripe Billing subscriptions may want to also update the subscription object directly.

Related field notes

Citations

On the problem:

  1. Stripe docs: the customer's default payment method is a separate setting from any single PaymentIntent's payment method. docs.stripe.com/api/customers/object
  2. Stripe docs: how Stripe Billing chooses which payment method to use for a subscription's invoices. docs.stripe.com/billing/subscriptions/payment-methods-setting
  3. WooCommerce docs: how the Stripe gateway saves cards and customer IDs against WooCommerce orders and subscriptions. woocommerce.com/document/stripe

On the solution:

  1. Stripe API: retrieve a PaymentIntent to read its payment method and amount received. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe API: attach a PaymentMethod to a customer and update invoice_settings.default_payment_method. docs.stripe.com/api/payment_methods/attach
  3. WooCommerce REST API: update an order and add an order note. 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 stuck renewals?

If this saved you a pile of failed renewal emails or a customer who thought they had already fixed it, 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