Reconciler Alternative payment methods
Old SEPA sources rejected
A subscription has renewed the same way for a year, then one month Stripe rejects the charge outright. Nothing about the shopper's bank account changed. What changed is Stripe: the saved payment token on that subscription is a legacy Source, and Stripe no longer accepts old Sources for off session SEPA Direct Debit renewals. Here is why it happens and a small script that finds every affected order and migrates it to a supported payment method.
Stripe deprecated the old Sources API for SEPA Direct Debit in favor of SEPA Debit PaymentMethods, and it now rejects off session renewal attempts against a legacy src_... token. Run a small Python or Node.js script on a schedule that reads the saved token from order meta _stripe_intent_id (or transaction_id), checks whether it is a legacy Source, and if the Stripe Customer already has a sepa_debit PaymentMethod, relinks the order to it. When no replacement exists, it flags the order so the shopper can re-enter their IBAN. Full code, tests, and a dry run guard are below.
The problem in plain words
Years ago, Stripe represented a SEPA Direct Debit mandate as a Source object, an id that starts with src_. WooCommerce Subscriptions saved that id and reused it every renewal period. Stripe later moved SEPA Direct Debit onto the PaymentMethods API, the same object type used for cards, and PaymentMethod ids start with pm_.
Stripe still charges most old Sources for a while as a courtesy, but it has been steadily tightening this, and a growing number of legacy SEPA Sources are now rejected outright on off session use, sometimes with the Source itself showing chargeable: false or canceled. WooCommerce has no idea any of this happened. It just sees the renewal fail and marks the order Failed or On hold, over and over, one billing cycle at a time.
Why it happens
Stripe's own migration docs describe the shift from Sources to PaymentMethods for SEPA Direct Debit, and note that Sources created before a store's plugin or integration adopted PaymentMethods keep working for a while but are not guaranteed forever. A few reasons this shows up now, even on subscriptions that have renewed fine for years:
- The store's WooCommerce Stripe gateway was upgraded at some point, and new subscriptions since then save a
pm_PaymentMethod, but subscriptions created before the upgrade still hold their originalsrc_Source. - Stripe periodically tightens which legacy Source types it will still process off session, and SEPA Sources are one of the categories being phased out for new charges.
- The Source's underlying mandate was invalidated on the bank side, which also causes Stripe to mark it
canceled, but this looks identical to the plain deprecation case from the order's point of view. - Nobody told WooCommerce Subscriptions any of this, so it keeps quietly retrying the exact same rejected token on every scheduled attempt.
This is a known pattern in the WooCommerce Stripe gateway's issue tracker, where stores report SEPA renewals failing with a generic "payment method not supported" style error while the same customer can check out fine with a new payment. See the citations at the end for the exact references.
The fix is not to retry the same charge harder. A rejected legacy Source will keep failing forever, because Stripe is not going to start accepting it again. The fix is to replace the token. If the shopper already has a modern sepa_debit PaymentMethod on their Stripe Customer, from a later manual payment for example, you can relink the subscription to it without bothering them. If they do not, the honest next step is asking them to re-enter their IBAN once.
The fix, as a flow
We do not touch a working subscription. We add a job that runs on a schedule, looks at orders that are pending, on hold, or failed, the states a stuck renewal sits in, and reads the saved Stripe token from each one. When that token is a legacy Source, we check the Stripe Customer for a newer SEPA Debit PaymentMethod. If one exists, we relink the order and add a note. If none exists, we flag the order so a human, or an automated email, can ask the shopper to reconnect their bank account.
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. 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 LOOKBACK_DAYS="30"
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 LOOKBACK_DAYS="30"
export DRY_RUN="true" // start safe, change to false to write
Read the saved token off the order
The WooCommerce Stripe gateway usually saves the payment token in order meta under _stripe_intent_id. Some older orders only have it in transaction_id. Either can hold a legacy Source id, which is easy to spot because it starts with src_ rather than pm_.
def token_of(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
return order.get("transaction_id") or None
def is_legacy_source(token):
return bool(token) and token.startswith("src_")
export function tokenOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
return order.transaction_id || null;
}
export function isLegacySource(token) {
return Boolean(token) && token.startsWith("src_");
}
Look for a modern SEPA PaymentMethod on the customer
Ask Stripe for the Customer's PaymentMethods of type sepa_debit. If the shopper made a later payment with a fresh mandate, or the gateway upgraded silently at some point, this list already has what we need. We take the newest one.
import stripe
def find_sepa_payment_method(customer_id):
if not customer_id:
return None
methods = stripe.PaymentMethod.list(customer=customer_id, type="sepa_debit", limit=10)
if not methods.data:
return None
newest = max(methods.data, key=lambda pm: pm.created)
return newest.id
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function findSepaPaymentMethod(customerId) {
if (!customerId) return null;
const methods = await stripe.paymentMethods.list({ customer: customerId, type: "sepa_debit", limit: 10 });
if (!methods.data.length) return null;
return methods.data.reduce((a, b) => (a.created > b.created ? a : b)).id;
}
Decide, with one pure function
Keep the decision in its own function that takes the order, the saved token, and any replacement PaymentMethod, and returns an action. This function touches no network and no database, so it is trivial to test with plain objects. The rule is simple. Only act on orders that are stuck on a renewal. Only act on a confirmed legacy Source. Migrate when a replacement exists, flag when it does not.
RENEWAL_STATUSES = {"pending", "on-hold", "failed"}
def decide(order, token, replacement_pm):
if order["status"] not in RENEWAL_STATUSES:
return ("skip", "order is not awaiting or retrying a renewal")
if not is_legacy_source(token):
return ("skip", "saved token is not a legacy Source")
if replacement_pm:
return ("migrate", "legacy Source found, a SEPA Debit PaymentMethod is available")
return ("flag", "legacy Source found, no SEPA Debit PaymentMethod on file")
const RENEWAL_STATUSES = new Set(["pending", "on-hold", "failed"]);
export function decide(order, token, replacementPm) {
if (!RENEWAL_STATUSES.has(order.status)) {
return ["skip", "order is not awaiting or retrying a renewal"];
}
if (!isLegacySource(token)) {
return ["skip", "saved token is not a legacy Source"];
}
if (replacementPm) {
return ["migrate", "legacy Source found, a SEPA Debit PaymentMethod is available"];
}
return ["flag", "legacy Source found, no SEPA Debit PaymentMethod on file"];
}
Relink the order, or flag it for the shopper
When the action is migrate, save the new PaymentMethod id onto the order meta and add a note explaining what happened. When the action is flag, add a note asking a human, or a triggered email, to have the shopper re-enter their IBAN, since there is no token left that Stripe will accept.
def migrate(order_id, replacement_pm):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"meta_data": [
{"key": "_stripe_intent_id", "value": replacement_pm},
{"key": "_stripe_source_id", "value": replacement_pm},
]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Migrated from a legacy Stripe SEPA Source to PaymentMethod "
f"{replacement_pm}. This order can now be retried."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag(order_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"SEPA payment check failed: {reason}. The shopper needs to "
f"re-enter their IBAN on the account page."},
auth=AUTH, timeout=30,
).raise_for_status()
async function migrate(orderId, replacementPm) {
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: "_stripe_intent_id", value: replacementPm },
{ key: "_stripe_source_id", value: replacementPm },
],
}),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Migrated from a legacy Stripe SEPA Source to PaymentMethod ${replacementPm}. ` +
`This order can now be retried.`,
}),
});
}
async function flag(orderId, reason) {
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `SEPA payment check failed: ${reason}. The shopper needs to re-enter ` +
`their IBAN on the account page.`,
}),
});
}
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 what it would do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once a day, since renewal retries are usually spaced out over days, not minutes.
Always start with DRY_RUN=true. This script writes to real orders and changes what payment token a subscription will charge next, so you want to see its plan before it acts. Once the report looks right for a day, 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 only ever replaces a confirmed legacy Source, never a token that is already a modern PaymentMethod.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find WooCommerce subscriptions still charging a legacy Stripe SEPA Source and
migrate them to a supported SEPA Debit PaymentMethod before the next renewal fails.
Safe by default (DRY_RUN=true). Run on a schedule.
"""
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("migrate_sepa_sources")
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"), os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"))
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RENEWAL_STATUSES = {"pending", "on-hold", "failed"}
LEGACY_SOURCE_PREFIX = "src_"
SEPA_PM_TYPE = "sepa_debit"
def token_of(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
return order.get("transaction_id") or None
def is_legacy_source(token):
return bool(token) and token.startswith(LEGACY_SOURCE_PREFIX)
def decide(order, token, replacement_pm):
if order["status"] not in RENEWAL_STATUSES:
return ("skip", "order is not awaiting or retrying a renewal")
if not is_legacy_source(token):
return ("skip", "saved token is not a legacy Source")
if replacement_pm:
return ("migrate", "legacy Source found, a SEPA Debit PaymentMethod is available")
return ("flag", "legacy Source found, no SEPA Debit PaymentMethod on file")
def find_sepa_payment_method(customer_id):
if not customer_id:
return None
methods = stripe.PaymentMethod.list(customer=customer_id, type=SEPA_PM_TYPE, limit=10)
if not methods.data:
return None
newest = max(methods.data, key=lambda pm: pm.created)
return newest.id
def renewal_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": "pending,on-hold,failed", "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 stripe_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 migrate(order, replacement_pm):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"meta_data": [
{"key": "_stripe_intent_id", "value": replacement_pm},
{"key": "_stripe_source_id", "value": replacement_pm},
]},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Migrated from a legacy Stripe SEPA Source to PaymentMethod "
f"{replacement_pm}. This order can now be retried or will use "
f"the new token on the next renewal."},
auth=AUTH, timeout=30,
).raise_for_status()
def flag(order, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"SEPA payment check failed: {reason}. This order is on a legacy "
f"Stripe Source that Stripe no longer accepts for renewals, and no "
f"replacement SEPA Debit PaymentMethod was found. The shopper needs "
f"to re-enter their IBAN on the account page before the next renewal."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
migrated = 0
flagged = 0
for order in renewal_orders():
token = token_of(order)
customer_id = stripe_customer_id_of(order)
replacement_pm = find_sepa_payment_method(customer_id) if is_legacy_source(token) else None
action, reason = decide(order, token, replacement_pm)
if action == "skip":
continue
log.info("Order %s: %s. %s", order["id"], reason, "would " + action if DRY_RUN else action + "ing")
if action == "migrate":
if not DRY_RUN:
migrate(order, replacement_pm)
migrated += 1
elif action == "flag":
if not DRY_RUN:
flag(order, reason)
flagged += 1
log.info(
"Done. %d order(s) %s, %d order(s) %s.",
migrated, "to migrate" if DRY_RUN else "migrated",
flagged, "to flag" if DRY_RUN else "flagged",
)
if __name__ == "__main__":
run()
/**
* Find WooCommerce subscriptions still charging a legacy Stripe SEPA Source and
* migrate them to a supported SEPA Debit PaymentMethod before the next renewal fails.
* Safe by default (DRY_RUN=true). Run on a schedule.
*/
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 || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const RENEWAL_STATUSES = new Set(["pending", "on-hold", "failed"]);
const LEGACY_SOURCE_PREFIX = "src_";
const SEPA_PM_TYPE = "sepa_debit";
export function tokenOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
return order.transaction_id || null;
}
export function isLegacySource(token) {
return Boolean(token) && token.startsWith(LEGACY_SOURCE_PREFIX);
}
export function decide(order, token, replacementPm) {
if (!RENEWAL_STATUSES.has(order.status)) {
return ["skip", "order is not awaiting or retrying a renewal"];
}
if (!isLegacySource(token)) {
return ["skip", "saved token is not a legacy Source"];
}
if (replacementPm) {
return ["migrate", "legacy Source found, a SEPA Debit PaymentMethod is available"];
}
return ["flag", "legacy Source found, no SEPA Debit PaymentMethod on file"];
}
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 findSepaPaymentMethod(customerId) {
if (!customerId) return null;
const methods = await stripe.paymentMethods.list({ customer: customerId, type: SEPA_PM_TYPE, limit: 10 });
if (!methods.data.length) return null;
return methods.data.reduce((a, b) => (a.created > b.created ? a : b)).id;
}
function stripeCustomerIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_customer_id" && meta.value) return meta.value;
}
return null;
}
async function* renewalOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=pending,on-hold,failed&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function migrate(order, replacementPm) {
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: "_stripe_intent_id", value: replacementPm },
{ key: "_stripe_source_id", value: replacementPm },
],
}),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Migrated from a legacy Stripe SEPA Source to PaymentMethod ${replacementPm}. ` +
`This order can now be retried or will use the new token on the next renewal.`,
}),
});
}
async function flag(order, reason) {
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `SEPA payment check failed: ${reason}. This order is on a legacy Stripe Source ` +
`that Stripe no longer accepts for renewals, and no replacement SEPA Debit ` +
`PaymentMethod was found. The shopper needs to re-enter their IBAN on the account page.`,
}),
});
}
export async function run() {
let migrated = 0;
let flagged = 0;
for await (const order of renewalOrders()) {
const token = tokenOf(order);
const customerId = stripeCustomerIdOf(order);
const replacementPm = isLegacySource(token) ? await findSepaPaymentMethod(customerId) : null;
const [action, reason] = decide(order, token, replacementPm);
if (action === "skip") continue;
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would " + action : action + "ing"}`);
if (action === "migrate") {
if (!DRY_RUN) await migrate(order, replacementPm);
migrated++;
} else if (action === "flag") {
if (!DRY_RUN) await flag(order, reason);
flagged++;
}
}
console.log(
`Done. ${migrated} order(s) ${DRY_RUN ? "to migrate" : "migrated"}, ` +
`${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which subscriptions get their payment token 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.
from migrate_sepa_sources import decide, is_legacy_source, token_of
def order(**over):
base = {"id": 501, "status": "pending"}
base.update(over)
return base
def test_migrate_when_legacy_source_and_replacement_exists():
assert decide(order(), "src_1AbCdEfGhIjKlMnO", "pm_1XyZ")[0] == "migrate"
def test_flag_when_legacy_source_and_no_replacement():
assert decide(order(), "src_1AbCdEfGhIjKlMnO", None)[0] == "flag"
def test_skip_when_token_is_not_a_legacy_source():
assert decide(order(), "pm_1XyZ", None)[0] == "skip"
def test_skip_when_order_not_in_renewal_status():
assert decide(order(status="completed"), "src_1AbCdEfGhIjKlMnO", "pm_1XyZ")[0] == "skip"
def test_token_of_falls_back_to_transaction_id():
o = {"meta_data": [], "transaction_id": "src_456"}
assert token_of(o) == "src_456"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, isLegacySource, tokenOf } from "./migrate-sepa-sources.js";
const order = (over = {}) => ({ id: 501, status: "pending", ...over });
test("migrate when legacy Source and replacement exists", () => {
assert.equal(decide(order(), "src_1AbCdEfGhIjKlMnO", "pm_1XyZ")[0], "migrate");
});
test("flag when legacy Source and no replacement", () => {
assert.equal(decide(order(), "src_1AbCdEfGhIjKlMnO", null)[0], "flag");
});
test("skip when token is not a legacy Source", () => {
assert.equal(decide(order(), "pm_1XyZ", null)[0], "skip");
});
test("skip when order not in a renewal status", () => {
assert.equal(decide(order({ status: "completed" }), "src_1AbCdEfGhIjKlMnO", "pm_1XyZ")[0], "skip");
});
test("tokenOf falls back to transaction_id", () => {
const o = { meta_data: [], transaction_id: "src_456" };
assert.equal(tokenOf(o), "src_456");
});
Case studies
The subscriptions that predated the upgrade
A store upgraded its WooCommerce Stripe gateway two years ago, and every subscription created since then saves a modern PaymentMethod. But around three hundred subscriptions from before the upgrade were still quietly holding their original SEPA Source, and one by one they started failing as Stripe tightened acceptance.
Running the migration script in dry run surfaced the full list in one pass. About seventy percent had a newer PaymentMethod already on the customer from a later payment, so those relinked automatically. The rest were flagged with a clear note for the support team to follow up.
The retries nobody was watching
WooCommerce Subscriptions retried a batch of failed renewals for weeks, each attempt hitting the same rejected Source and failing the same way. Nobody noticed because the failure emails went to a mailbox nobody checked, and the store just saw slowly declining active subscriber counts.
Once the script ran on a daily schedule, it caught the pattern immediately: every one of those orders had a src_ token with no newer PaymentMethod behind it. Flagging them let the team send a single targeted email asking those shoppers to reconnect their bank account, recovering most of the revenue that was quietly leaking away.
After this runs on a schedule, a legacy SEPA Source stops being a silent, repeating failure. Subscriptions with a usable replacement token get fixed without anyone noticing. Subscriptions without one get a clear, single ask to the shopper instead of an endless string of failed retries. Keep it running even after the initial backlog clears, since Stripe keeps tightening which old tokens it accepts.
FAQ
Why does Stripe reject an old SEPA Source on renewal?
Stripe replaced the legacy Sources API with PaymentMethods for SEPA Direct Debit. A subscription that saved a src_ Source before the switch will have that Source rejected or marked as no longer chargeable when the next renewal tries to charge it off session. Migrating the saved token to a sepa_debit PaymentMethod on the same customer fixes it.
Can I migrate a SEPA Source to a PaymentMethod without asking the shopper to pay again?
Yes, if the shopper already has a SEPA Debit PaymentMethod attached to their Stripe Customer, for example from a later manual payment or a re-entered mandate. The migration script checks for that PaymentMethod first and only asks the shopper to re-enter their IBAN when none exists.
Is it safe to change an order's saved payment token with a script?
Yes, when the script only touches orders that are awaiting or retrying a renewal and only replaces a confirmed legacy Source with a real SEPA Debit PaymentMethod already on file for that customer. Start in dry run mode to review the list before it writes.
Related field notes
Citations
On the problem:
- Stripe docs: SEPA Direct Debit payments and the move from Sources to PaymentMethods. docs.stripe.com/payments/sepa-debit
- Stripe docs: migrating from Sources to PaymentIntents and PaymentMethods. docs.stripe.com/payments/payment-methods/migrating-to-payment-methods
- WooCommerce Stripe gateway issue tracker: SEPA renewal failures on older saved sources. github.com/woocommerce/woocommerce-gateway-stripe/issues
On the solution:
- Stripe API: list a Customer's PaymentMethods filtered by type. docs.stripe.com/api/payment_methods/customer_list
- Stripe docs: SEPA Direct Debit mandates and reusing a PaymentMethod off session. docs.stripe.com/payments/sepa-debit/set-up-payment
- 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.
Did this fix your stuck orders?
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