Repair Sources to PaymentMethods and SCA
Add an off session mandate to old WooCommerce subscriptions
A subscription that has renewed cleanly for years suddenly starts failing. The card has not expired, the customer has not disputed anything, and Stripe rejects the charge with requires_action instead of taking the money. The subscription was saved before Strong Customer Authentication became mandatory, so it never picked up an off session mandate, the piece of paperwork Stripe now insists on before letting a merchant bill a card while the customer is not there. Here is why it happens and a small job that finds every old subscription missing a mandate and attaches one safely.
The subscription's saved PaymentMethod was confirmed before SCA required an off session mandate, so Stripe has nothing on file that proves the customer agreed to future off session charges. Run a small Python or Node.js job on a schedule that reads the PaymentMethod behind each active subscription's saved PaymentIntent, checks whether an off session SetupIntent has ever succeeded for it, and if not, confirms a zero amount SetupIntent with usage=off_session to attach the mandate. Full code, tests, and a dry run guard are below.
The problem in plain words
When a customer pays for the first time, they are on your checkout page. Stripe treats that as an on session payment, since the cardholder is present and can complete any extra verification the bank asks for. Older stores saved the card right there and never did anything more with it.
Renewals are different. Nobody is on the checkout page three months later when the subscription tries to bill again. Stripe calls this an off session payment, and it will only let a merchant make one when there is a mandate on file, a record that the customer agreed in advance that this saved card can be charged later without them present. Subscriptions set up before this rule existed simply never created that record, so every renewal now looks, to Stripe, like an unauthorized charge attempt.
Why it happens
Stripe's own SCA documentation is explicit that off session charges need a mandate created through a confirmed SetupIntent or PaymentIntent with setup_future_usage, and that this became a hard requirement as SCA rolled out across Europe and beyond. A few reasons a store ends up with subscriptions that never got one:
- The subscription predates the WooCommerce Stripe gateway's SCA support, back when Sources or early PaymentMethods were saved with no future usage intent at all.
- The store migrated from Stripe Sources to PaymentMethods, and the migration copied the card details but not a mandate, since a Source never had one.
- A support agent manually attached a new card to a subscription outside the normal checkout flow, so the confirmation step that creates a mandate was skipped entirely.
- The customer switched banks or cards through a self-service flow that saved the new card on session, but the store's renewal logic assumed a mandate already existed and never checked.
This is documented behavior, not a bug in WooCommerce Subscriptions or the Stripe gateway. Stripe designed off session mandates specifically to satisfy the regulation, and it means any card saved before that requirement will eventually hit a renewal it cannot complete. See the citations at the end for the exact references.
A mandate is attached to a specific Stripe PaymentMethod, not to the subscription or the order. Once one succeeded off session SetupIntent exists for a PaymentMethod, every future renewal against that same PaymentMethod can reuse it. The fix is a one time repair per PaymentMethod, not something that needs to run before every renewal.
The fix, as a flow
We do not touch the renewal schedule or the subscription price. We add a job that walks active and on-hold subscriptions, finds the Stripe PaymentMethod behind the parent order's saved PaymentIntent, and checks whether a prior off session SetupIntent ever succeeded for it. If not, we confirm a new zero amount SetupIntent with usage=off_session against that same PaymentMethod. That one confirmation creates the mandate the next renewal will need.
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 read and write access to orders and 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 subscriptions that are still running
Pull subscriptions with status active or on-hold from the WooCommerce Subscriptions REST endpoint. On-hold is included because a subscription that already failed once for lack of a mandate typically sits there until someone repairs it.
import requests
from requests.auth import HTTPBasicAuth
AUTH = HTTPBasicAuth(WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET)
def active_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,on-hold", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
yield sub
page += 1
async function* activeSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
Read the PaymentMethod behind the parent order
The subscription's parent order carries the Stripe PaymentIntent id, saved in order meta _stripe_intent_id or, on older orders, in transaction_id directly. Retrieve that PaymentIntent from Stripe, then retrieve the PaymentMethod it points to. That PaymentMethod is what the next renewal will try to charge.
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_payment_method(order):
intent_id = intent_id_of(order)
if not intent_id:
return None
intent = stripe.PaymentIntent.retrieve(intent_id)
pm_id = intent.get("payment_method")
if not pm_id:
return None
return stripe.PaymentMethod.retrieve(pm_id)
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 getPaymentMethod(order) {
const intentId = intentIdOf(order);
if (!intentId) return null;
const intent = await stripe.paymentIntents.retrieve(intentId);
if (!intent.payment_method) return null;
return stripe.paymentMethods.retrieve(intent.payment_method);
}
Check for an existing mandate, with one pure function
Stripe has no single boolean field for "this PaymentMethod already has an off session mandate", so we look for it ourselves by listing SetupIntents for the customer and checking whether one succeeded with usage=off_session against this exact PaymentMethod. We attach the result as a plain field before calling the decision function, which stays pure and easy to test. The rule is simple. If the subscription is not running, skip it. If there is no saved PaymentMethod, flag it for manual review since there is nothing to attach a mandate to. If a mandate already exists, leave it alone. Otherwise, attach one.
ACTIVE_SUB_STATUSES = {"active", "on-hold"}
def decide(subscription, payment_method):
if subscription["status"] not in ACTIVE_SUB_STATUSES:
return ("skip", "subscription is not active or on-hold")
if payment_method is None:
return ("no_payment_method", "no saved PaymentMethod on the parent order")
if payment_method.get("type") not in ("card", "sepa_debit", "us_bank_account"):
return ("skip", "payment method type does not support an off session mandate")
if payment_method.get("off_session_mandate"):
return ("ok", "already has an off session mandate")
return ("attach_mandate", "no off session mandate found, needs one before the next renewal")
const ACTIVE_SUB_STATUSES = new Set(["active", "on-hold"]);
export function decide(subscription, paymentMethod) {
if (!ACTIVE_SUB_STATUSES.has(subscription.status)) {
return ["skip", "subscription is not active or on-hold"];
}
if (!paymentMethod) {
return ["no_payment_method", "no saved PaymentMethod on the parent order"];
}
if (!["card", "sepa_debit", "us_bank_account"].includes(paymentMethod.type)) {
return ["skip", "payment method type does not support an off session mandate"];
}
if (paymentMethod.off_session_mandate) {
return ["ok", "already has an off session mandate"];
}
return ["attach_mandate", "no off session mandate found, needs one before the next renewal"];
}
Attach the mandate with a zero amount SetupIntent
When the action is attach_mandate, confirm a SetupIntent against the same customer and PaymentMethod with usage=off_session, confirm=true, and off_session=true. No money moves. This single confirmation is what creates the mandate, and every renewal against this PaymentMethod from now on can reuse it. Add an order note so the shop manager can see the repair happened and why.
def attach_mandate(order, payment_method):
setup_intent = stripe.SetupIntent.create(
customer=payment_method["customer"],
payment_method=payment_method["id"],
usage="off_session",
confirm=True,
off_session=True,
)
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Attached an off session mandate to PaymentMethod {payment_method['id']} "
f"via SetupIntent {setup_intent['id']}. Future renewals can now charge "
f"this card without the customer present."},
auth=AUTH, timeout=30,
).raise_for_status()
return setup_intent
async function attachMandate(order, paymentMethod) {
const setupIntent = await stripe.setupIntents.create({
customer: paymentMethod.customer,
payment_method: paymentMethod.id,
usage: "off_session",
confirm: true,
off_session: true,
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Attached an off session mandate to PaymentMethod ${paymentMethod.id} via ` +
`SetupIntent ${setupIntent.id}. Future renewals can now charge this card ` +
`without the customer present.`,
}),
});
return setupIntent;
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which subscriptions would get a mandate. Read the output, trust it, then switch it off to let it write. This job does not need to run every few minutes like a webhook reconciler. Once a week is plenty, since a PaymentMethod only ever needs one mandate.
Always start with DRY_RUN=true. Confirming a SetupIntent is a real call to Stripe against a real customer's card, so you want to see the full list of subscriptions it would touch before it acts. Once the report looks right, turn it off.
The full code
Here is the complete repair job 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 re-confirms a mandate that already succeeded.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Attach a valid off session mandate to old WooCommerce Subscriptions orders.
Subscriptions created before Strong Customer Authentication (SCA) became the norm
often saved a card as a plain Stripe Source, or as a PaymentMethod that was only
ever confirmed on session (the shopper was on the checkout page). Stripe requires
an off session mandate before it will let a merchant charge a saved PaymentMethod
without the customer present. Without one, the renewal PaymentIntent comes back
with status requires_action and the subscription goes on-hold.
This walks active subscriptions, reads the saved PaymentMethod from the parent
order, and for any PaymentMethod that has never completed an off session
confirmation, runs a zero amount off session SetupIntent to attach a mandate.
That mandate is then reused by every future renewal. Read only by default
(DRY_RUN=true). Safe to run again and again, since a PaymentMethod that already
has a mandate is left alone.
"""
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("attach_off_session_mandate")
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"
ACTIVE_SUB_STATUSES = {"active", "on-hold"}
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 decide(subscription, payment_method):
"""Pure decision. No network calls in here, so it is easy to unit test."""
if subscription["status"] not in ACTIVE_SUB_STATUSES:
return ("skip", "subscription is not active or on-hold")
if payment_method is None:
return ("no_payment_method", "no saved PaymentMethod on the parent order")
if payment_method.get("type") not in ("card", "sepa_debit", "us_bank_account"):
return ("skip", "payment method type does not support an off session mandate")
if payment_method.get("off_session_mandate"):
return ("ok", "already has an off session mandate")
return ("attach_mandate", "no off session mandate found, needs one before the next renewal")
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def get_order(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def active_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,on-hold", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
yield sub
page += 1
def get_payment_method(order):
"""Look up the Stripe PaymentMethod behind the order's saved PaymentIntent."""
intent_id = intent_id_of(order)
if not intent_id:
return None
intent = stripe.PaymentIntent.retrieve(intent_id)
pm_id = intent.get("payment_method")
if not pm_id:
return None
pm = stripe.PaymentMethod.retrieve(pm_id)
pm["off_session_mandate"] = _existing_mandate(pm)
return pm
def _existing_mandate(pm):
customer_id = pm.get("customer")
if not customer_id:
return None
setup_intents = stripe.SetupIntent.list(customer=customer_id, limit=20)
for si in setup_intents.auto_paging_iter():
if (
si.get("payment_method") == pm["id"]
and si.get("usage") == "off_session"
and si.get("status") == "succeeded"
):
return si["id"]
return None
def attach_mandate(order, payment_method):
"""Confirm a zero amount off session SetupIntent to record a mandate."""
setup_intent = stripe.SetupIntent.create(
customer=payment_method["customer"],
payment_method=payment_method["id"],
usage="off_session",
confirm=True,
off_session=True,
)
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Attached an off session mandate to PaymentMethod {payment_method['id']} "
f"via SetupIntent {setup_intent['id']}. Future renewals can now charge "
f"this card without the customer present."},
auth=AUTH, timeout=30,
).raise_for_status()
return setup_intent
def run():
attached = 0
for sub in active_subscriptions():
parent_order_id = sub.get("parent_id") or sub["id"]
order = get_order(parent_order_id)
if order is None:
log.warning("Subscription %s has no matching parent order %s", sub["id"], parent_order_id)
continue
payment_method = get_payment_method(order)
action, reason = decide(sub, payment_method)
if action in ("skip", "ok"):
continue
if action == "no_payment_method":
log.warning("Subscription %s: %s", sub["id"], reason)
continue
log.info("Subscription %s: %s. %s", sub["id"], reason, "would attach" if DRY_RUN else "attaching")
if not DRY_RUN:
attach_mandate(order, payment_method)
attached += 1
log.info("Done. %d subscription(s) %s.", attached, "need a mandate" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Attach a valid off session mandate to old WooCommerce Subscriptions orders.
*
* Subscriptions created before Strong Customer Authentication (SCA) became the
* norm often saved a card as a plain Stripe Source, or as a PaymentMethod that
* was only ever confirmed on session (the shopper was on the checkout page).
* Stripe requires an off session mandate before it will let a merchant charge a
* saved PaymentMethod without the customer present. Without one, the renewal
* PaymentIntent comes back with status requires_action and the subscription
* goes on-hold.
*
* This walks active subscriptions, reads the saved PaymentMethod from the
* parent order, and for any PaymentMethod that has never completed an off
* session confirmation, runs a zero amount off session SetupIntent to attach a
* mandate. Read only by default (DRY_RUN=true).
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ACTIVE_SUB_STATUSES = new Set(["active", "on-hold"]);
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 decide(subscription, paymentMethod) {
if (!ACTIVE_SUB_STATUSES.has(subscription.status)) {
return ["skip", "subscription is not active or on-hold"];
}
if (!paymentMethod) {
return ["no_payment_method", "no saved PaymentMethod on the parent order"];
}
if (!["card", "sepa_debit", "us_bank_account"].includes(paymentMethod.type)) {
return ["skip", "payment method type does not support an off session mandate"];
}
if (paymentMethod.off_session_mandate) {
return ["ok", "already has an off session mandate"];
}
return ["attach_mandate", "no off session mandate found, needs one before the next renewal"];
}
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* activeSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
async function existingMandate(pm) {
const customerId = pm.customer;
if (!customerId) return null;
for await (const si of stripe.setupIntents.list({ customer: customerId, limit: 20 })) {
if (si.payment_method === pm.id && si.usage === "off_session" && si.status === "succeeded") {
return si.id;
}
}
return null;
}
async function getPaymentMethod(order) {
const intentId = intentIdOf(order);
if (!intentId) return null;
const intent = await stripe.paymentIntents.retrieve(intentId);
if (!intent.payment_method) return null;
const pm = await stripe.paymentMethods.retrieve(intent.payment_method);
pm.off_session_mandate = await existingMandate(pm);
return pm;
}
async function attachMandate(order, paymentMethod) {
const setupIntent = await stripe.setupIntents.create({
customer: paymentMethod.customer,
payment_method: paymentMethod.id,
usage: "off_session",
confirm: true,
off_session: true,
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Attached an off session mandate to PaymentMethod ${paymentMethod.id} via ` +
`SetupIntent ${setupIntent.id}. Future renewals can now charge this card ` +
`without the customer present.`,
}),
});
return setupIntent;
}
export async function run() {
let attached = 0;
for await (const sub of activeSubscriptions()) {
const parentOrderId = sub.parent_id || sub.id;
const order = await woo(`/orders/${parentOrderId}`);
if (!order) {
console.warn(`Subscription ${sub.id} has no matching parent order ${parentOrderId}`);
continue;
}
const paymentMethod = await getPaymentMethod(order);
const [action, reason] = decide(sub, paymentMethod);
if (action === "skip" || action === "ok") continue;
if (action === "no_payment_method") {
console.warn(`Subscription ${sub.id}: ${reason}`);
continue;
}
console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would attach" : "attaching"}`);
if (!DRY_RUN) await attachMandate(order, paymentMethod);
attached++;
}
console.log(`Done. ${attached} subscription(s) ${DRY_RUN ? "need a mandate" : "fixed"}.`);
}
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 whether a real customer's card gets a new SetupIntent confirmed against it. 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 attach_off_session_mandate import decide
def subscription(**over):
base = {"id": 501, "status": "active"}
base.update(over)
return base
def payment_method(**over):
base = {"id": "pm_1", "type": "card", "customer": "cus_1", "off_session_mandate": None}
base.update(over)
return base
def test_attach_mandate_when_card_has_none():
assert decide(subscription(), payment_method())[0] == "attach_mandate"
def test_ok_when_mandate_already_exists():
pm = payment_method(off_session_mandate="seti_123")
assert decide(subscription(), pm)[0] == "ok"
def test_no_payment_method_when_none_saved():
assert decide(subscription(), None)[0] == "no_payment_method"
def test_skip_when_subscription_not_active():
sub = subscription(status="cancelled")
assert decide(sub, payment_method())[0] == "skip"
def test_attach_mandate_for_on_hold_subscription():
sub = subscription(status="on-hold")
assert decide(sub, payment_method())[0] == "attach_mandate"
def test_skip_for_unsupported_payment_method_type():
pm = payment_method(type="alipay")
assert decide(subscription(), pm)[0] == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./attach-off-session-mandate.js";
const subscription = (over = {}) => ({ id: 501, status: "active", ...over });
const paymentMethod = (over = {}) => ({ id: "pm_1", type: "card", customer: "cus_1", off_session_mandate: null, ...over });
test("attach_mandate when card has none", () => {
assert.equal(decide(subscription(), paymentMethod())[0], "attach_mandate");
});
test("ok when mandate already exists", () => {
const pm = paymentMethod({ off_session_mandate: "seti_123" });
assert.equal(decide(subscription(), pm)[0], "ok");
});
test("no_payment_method when none saved", () => {
assert.equal(decide(subscription(), null)[0], "no_payment_method");
});
test("skip when subscription not active", () => {
const sub = subscription({ status: "cancelled" });
assert.equal(decide(sub, paymentMethod())[0], "skip");
});
test("attach_mandate for on-hold subscription", () => {
const sub = subscription({ status: "on-hold" });
assert.equal(decide(sub, paymentMethod())[0], "attach_mandate");
});
test("skip for unsupported payment method type", () => {
const pm = paymentMethod({ type: "alipay" });
assert.equal(decide(subscription(), pm)[0], "skip");
});
Case studies
The store that moved from Sources to PaymentMethods and left mandates behind
A shop upgraded its Stripe gateway plugin, which migrated old Stripe Sources to PaymentMethods automatically. The card details carried over cleanly, but a Source never had an off session mandate to begin with, so none of the migrated PaymentMethods had one either. Renewals started failing gradually as each subscription hit its next billing date.
The team ran the repair job in dry run first, saw a list of a few hundred subscriptions with no mandate, then ran it for real overnight. Every PaymentMethod picked up a mandate through a zero amount SetupIntent, and the next round of renewals went through without anyone needing to re-enter a card.
The subscription support fixed by hand, and broke by hand
A support agent manually attached a customer's new card to their subscription through the admin, outside the usual checkout flow, after the old card was declined. The new PaymentMethod saved fine, but the manual attach never ran the confirmation step Stripe needs to record a mandate.
The next renewal after that "fix" failed with requires_action, confusing everyone since the card itself was valid. The repair job flagged the PaymentMethod as missing a mandate on its next run and attached one, and the subscription resumed renewing normally from there.
Once every active PaymentMethod behind a subscription carries an off session mandate, renewals stop hitting requires_action for this reason entirely. Run the job once after any migration, plugin upgrade, or bulk card change, and keep it on a light weekly schedule afterward to catch the occasional manually attached card.
FAQ
Why do old WooCommerce subscriptions fail to renew with requires_action?
The subscription saved a card before Strong Customer Authentication (SCA) rules existed, so Stripe has no off session mandate for it. When the merchant tries to charge the card while the customer is not present, Stripe cannot confirm the charge is authorized and returns requires_action instead of completing it.
What is an off session mandate and how do I attach one?
It is Stripe's record that a customer agreed a saved payment method can be charged later without them present. You attach one by running a zero amount SetupIntent with usage off_session, confirmed while off_session is true, against the same PaymentMethod the subscription already uses.
Is it safe to run a mandate repair job against live subscriptions?
Yes, when it only touches subscriptions that are active or on-hold, skips any PaymentMethod that already has a mandate, and never changes the order amount or status itself. Start in dry run mode so you can see the full list before anything is confirmed with Stripe.
Related field notes
Citations
On the problem:
- Stripe docs: Strong Customer Authentication and how off session payments require a mandate before they can be confirmed. docs.stripe.com/strong-customer-authentication
- Stripe docs: migrating from Sources to PaymentMethods, and why a migrated Source has no mandate history. docs.stripe.com/payments/payment-methods/migrating-to-payment-methods
- WooCommerce Subscriptions docs: renewal payments and how failed off session charges affect subscription status. woocommerce.com/document/subscriptions/renewal-process
On the solution:
- Stripe docs: setting up future payments and creating an off session mandate with a SetupIntent. docs.stripe.com/payments/save-and-reuse
- Stripe API: the SetupIntent object, its usage and off_session parameters. docs.stripe.com/api/setup_intents
- WooCommerce REST API: reading and updating subscriptions and orders. 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 failed renewals or a wave of cancellations, 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