Diagnostic Alternative payment methods
iDEAL and Bancontact for renewals
The first order looked completely normal. The customer picked iDEAL or Bancontact at checkout, approved the payment in their banking app, and the order came through paid. Nobody thought about it again until the first renewal date arrived and quietly failed with no card to charge. iDEAL and Bancontact are one off methods, so a subscription that starts on either one is stuck the moment WooCommerce Subscriptions tries to bill it automatically. Here is why that happens and a small script that finds every subscription sitting in this trap and asks the customer for a reusable card before the renewal date, not after.
iDEAL and Bancontact are redirect based, one off payment methods. Stripe never attaches a reusable payment method to the customer after either one, so WooCommerce Subscriptions has nothing to charge when the renewal date comes around. Run a small Python or Node.js script on a schedule that reads each subscription's original PaymentIntent, checks whether it used iDEAL or Bancontact with no reusable method saved, and if the next renewal is close, adds a note and flags the subscription so the shop can ask the customer for a card. Full code, tests, and a dry run guard are below.
The problem in plain words
iDEAL and Bancontact are bank redirect methods. The customer leaves your checkout, approves the payment on their own bank's site or app, and comes back. That approval is a one time action tied to that single payment. Stripe has no way to charge the same bank account again later without the customer going through that same redirect and approval step, so it does not create a reusable payment method for the customer behind an iDEAL or Bancontact payment.
WooCommerce Subscriptions does not know any of this at checkout time. The first order goes through, the subscription is created as active, and everything looks fine. The gap only becomes visible on the next renewal date, when WooCommerce Subscriptions tries to charge the saved payment method automatically and finds there is not one, or Stripe declines the attempt outright because the method cannot be charged off session.
Why it happens
Stripe's own documentation is direct about this: iDEAL and Bancontact are both bank redirect methods that Stripe treats as single use unless the payment is explicitly set up to save a different, reusable method behind it. A few things make this worse in a WooCommerce store:
- The checkout offers iDEAL or Bancontact next to cards without telling the shopper that a subscription needs a repeatable payment method, so people pick whatever they use every day for one off purchases.
- WooCommerce Subscriptions creates the subscription as active right after the first successful order, the same as it would for a card, so there is no early warning that anything is different.
- Some Stripe integrations do combine iDEAL with a SEPA Direct Debit mandate behind the scenes, which is reusable, but only when that mandate flow is set up on purpose. Left at the defaults, most stores do not get it.
- The failure only surfaces days, weeks, or months later on the renewal date, by which point the customer has forgotten which method they used for the first order.
This is a known limitation of both methods, not a bug in WooCommerce or Stripe. See the citations at the end for the exact documentation.
The PaymentIntent behind the first order already tells you the whole story. If payment_method_types includes ideal or bancontact and the customer has no other reusable payment method attached in Stripe, the subscription is guaranteed to fail its next automatic renewal. You do not need to wait for that failure. A scheduled check can catch it days ahead and turn it into an email instead of a declined charge.
The fix, as a flow
We do not touch checkout and we do not try to charge anything. We add a job that runs on a schedule, looks at active subscriptions whose next renewal is coming up soon, reads the PaymentIntent from the order that started each one, and checks whether the payment method used was a one off type with no reusable method behind it. When that is true and the renewal is close, we flag the subscription with a note so a human can reach out and ask the customer to add a card.
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 access to subscriptions and 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 RENEWAL_WINDOW_DAYS="7"
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 RENEWAL_WINDOW_DAYS="7"
export DRY_RUN="true" // start safe, change to false to write
List active subscriptions with a renewal coming up
Ask the WooCommerce REST API for subscriptions that are active and whose next_payment_date falls inside your renewal window. There is no reason to look at subscriptions that just renewed or that will not renew for another month.
import os, requests
from datetime import datetime, timedelta
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 due_soon_subscriptions(window_days):
cutoff = datetime.utcnow() + timedelta(days=window_days)
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
next_payment = sub.get("next_payment_date_gmt")
if next_payment and datetime.fromisoformat(next_payment) <= cutoff:
yield sub
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
async function* dueSoonSubscriptions(windowDays) {
const cutoff = Date.now() + windowDays * 86400000;
let page = 1;
while (true) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/subscriptions?status=active&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 nextPayment = sub.next_payment_date_gmt;
if (nextPayment && new Date(nextPayment + "Z").getTime() <= cutoff) yield sub;
}
page++;
}
}
Read the PaymentIntent behind the first order
Each subscription links back to a parent order. Read that order's PaymentIntent id from order meta _stripe_intent_id, falling back to transaction_id when it looks like a PaymentIntent id, then retrieve the intent from Stripe to see which payment method type actually paid it and whether a reusable method is attached to the customer.
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, expand=["payment_method"])
except stripe.error.InvalidRequestError:
return None
def has_reusable_method(customer_id):
if not customer_id:
return False
methods = stripe.PaymentMethod.list(customer=customer_id, type="card")
return len(methods.data) > 0
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(stripe, intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId, { expand: ["payment_method"] });
} catch {
return null;
}
}
async function hasReusableMethod(stripe, customerId) {
if (!customerId) return false;
const methods = await stripe.paymentMethods.list({ customer: customerId, type: "card" });
return methods.data.length > 0;
}
Decide, with one pure function
Keep the decision in its own function that takes a subscription, its PaymentIntent, and whether a reusable method exists for the customer, and returns an action. The rule is simple. A one off method is only iDEAL or Bancontact with no reusable card on file. If the renewal is not close, skip it even if the method is risky, since there is no urgency yet.
ONE_OFF_METHOD_TYPES = {"ideal", "bancontact"}
def decide(subscription, intent, has_reusable, days_until_renewal):
if intent is None:
return ("skip", "no PaymentIntent found for the first order")
method_types = set(intent.get("payment_method_types") or [])
if not method_types & ONE_OFF_METHOD_TYPES:
return ("skip", "first payment used a reusable method")
if has_reusable:
return ("ok", "a reusable card is already on file")
if days_until_renewal > subscription.get("renewal_window_days", 7):
return ("skip", "renewal is not close enough yet")
return ("flag", "one off method with no reusable card before renewal")
const ONE_OFF_METHOD_TYPES = new Set(["ideal", "bancontact"]);
export function decide(subscription, intent, hasReusable, daysUntilRenewal) {
if (!intent) return ["skip", "no PaymentIntent found for the first order"];
const methodTypes = new Set(intent.payment_method_types || []);
const isOneOff = [...methodTypes].some((t) => ONE_OFF_METHOD_TYPES.has(t));
if (!isOneOff) return ["skip", "first payment used a reusable method"];
if (hasReusable) return ["ok", "a reusable card is already on file"];
const windowDays = subscription.renewal_window_days || 7;
if (daysUntilRenewal > windowDays) return ["skip", "renewal is not close enough yet"];
return ["flag", "one off method with no reusable card before renewal"];
}
Ask for a card, do not try to charge anything
When the action is flag, add a note on the subscription so a human sees it, and post a note on the parent order too, so the reason is visible wherever the shop manager is looking. This script never attempts a charge. The only safe next step is asking the customer to add a card.
def flag_subscription(subscription_id, order_id, reason):
note = (f"Renewal risk: {reason}. The first payment used a one off method "
f"(iDEAL or Bancontact) and no reusable card is on file. "
f"Ask the customer to add a card before the next renewal date.")
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{subscription_id}/notes",
json={"note": note}, auth=AUTH, timeout=30,
).raise_for_status()
if order_id and order_id != subscription_id:
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": note}, auth=AUTH, timeout=30,
).raise_for_status()
async function flagSubscription(subscriptionId, orderId, reason) {
const note = `Renewal risk: ${reason}. The first payment used a one off method ` +
`(iDEAL or Bancontact) and no reusable card is on file. ` +
`Ask the customer to add a card before the next renewal date.`;
await woo(`/orders/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
if (orderId && orderId !== subscriptionId) {
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
}
}
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 flag. Read the output, trust it, then switch it off to let it write notes. Run it once a day with cron, since renewal dates do not move minute to minute.
Always start with DRY_RUN=true. This script only reads Stripe and writes notes, it never charges a card or changes a subscription's status, but you still want to see its plan before it writes anything to real orders.
The full code
Here is the complete check 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 subscription that already has a reusable card on file.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find WooCommerce subscriptions that started on iDEAL or Bancontact and have
no reusable payment method on file before their next renewal date.
iDEAL and Bancontact are one off, redirect based methods. Stripe does not attach
a reusable payment method to the customer behind either one, so a subscription
stuck on one of them will fail its next automatic renewal unless a human asks
the customer to add a card first. Read only by default. Run on a schedule.
"""
import os
import logging
from datetime import datetime, timedelta, timezone
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_one_off_methods")
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"])
RENEWAL_WINDOW_DAYS = int(os.environ.get("RENEWAL_WINDOW_DAYS", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ONE_OFF_METHOD_TYPES = {"ideal", "bancontact"}
def due_soon_subscriptions(window_days):
cutoff = datetime.now(timezone.utc) + timedelta(days=window_days)
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
next_payment = sub.get("next_payment_date_gmt")
if next_payment and datetime.fromisoformat(next_payment).replace(tzinfo=timezone.utc) <= cutoff:
yield sub
page += 1
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_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 get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id, expand=["payment_method"])
except stripe.error.InvalidRequestError:
return None
def has_reusable_method(customer_id):
if not customer_id:
return False
methods = stripe.PaymentMethod.list(customer=customer_id, type="card")
return len(methods.data) > 0
def days_until(next_payment_date_gmt):
when = datetime.fromisoformat(next_payment_date_gmt).replace(tzinfo=timezone.utc)
return max(0, (when - datetime.now(timezone.utc)).days)
def decide(subscription, intent, has_reusable, days_until_renewal):
if intent is None:
return ("skip", "no PaymentIntent found for the first order")
method_types = set(intent.get("payment_method_types") or [])
if not method_types & ONE_OFF_METHOD_TYPES:
return ("skip", "first payment used a reusable method")
if has_reusable:
return ("ok", "a reusable card is already on file")
if days_until_renewal > subscription.get("renewal_window_days", RENEWAL_WINDOW_DAYS):
return ("skip", "renewal is not close enough yet")
return ("flag", "one off method with no reusable card before renewal")
def flag_subscription(subscription_id, order_id, reason):
note = (f"Renewal risk: {reason}. The first payment used a one off method "
f"(iDEAL or Bancontact) and no reusable card is on file. "
f"Ask the customer to add a card before the next renewal date.")
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{subscription_id}/notes",
json={"note": note}, auth=AUTH, timeout=30,
).raise_for_status()
if order_id and order_id != subscription_id:
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": note}, auth=AUTH, timeout=30,
).raise_for_status()
def run():
flagged = 0
for sub in due_soon_subscriptions(RENEWAL_WINDOW_DAYS):
parent_order_id = sub.get("parent_id") or sub["id"]
order = get_order(parent_order_id)
intent = get_intent(intent_id_of(order)) if order else None
customer_id = intent.get("customer") if intent else None
reusable = has_reusable_method(customer_id)
remaining = days_until(sub["next_payment_date_gmt"])
action, reason = decide(sub, intent, reusable, remaining)
if action != "flag":
continue
log.warning("Subscription %s: %s. %s", sub["id"], reason, "would flag" if DRY_RUN else "flagging")
if not DRY_RUN:
flag_subscription(sub["id"], parent_order_id, reason)
flagged += 1
log.info("Done. %d subscription(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Find WooCommerce subscriptions that started on iDEAL or Bancontact and have
* no reusable payment method on file before their next renewal date.
*
* iDEAL and Bancontact are one off, redirect based methods. Stripe does not
* attach a reusable payment method to the customer behind either one, so a
* subscription stuck on one of them will fail its next automatic renewal
* unless a human asks the customer to add a card first. Read only by
* default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/ideal-and-bancontact-for-renewals/
*/
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 RENEWAL_WINDOW_DAYS = Number(process.env.RENEWAL_WINDOW_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ONE_OFF_METHOD_TYPES = new Set(["ideal", "bancontact"]);
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* dueSoonSubscriptions(windowDays) {
const cutoff = Date.now() + windowDays * 86400000;
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const sub of batch) {
const nextPayment = sub.next_payment_date_gmt;
if (nextPayment && new Date(nextPayment + "Z").getTime() <= cutoff) yield sub;
}
page++;
}
}
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, { expand: ["payment_method"] });
} catch {
return null;
}
}
async function hasReusableMethod(customerId) {
if (!customerId) return false;
const methods = await stripe.paymentMethods.list({ customer: customerId, type: "card" });
return methods.data.length > 0;
}
export function daysUntil(nextPaymentDateGmt) {
const when = new Date(nextPaymentDateGmt + "Z").getTime();
return Math.max(0, Math.floor((when - Date.now()) / 86400000));
}
export function decide(subscription, intent, hasReusable, daysUntilRenewal) {
if (!intent) return ["skip", "no PaymentIntent found for the first order"];
const methodTypes = new Set(intent.payment_method_types || []);
const isOneOff = [...methodTypes].some((t) => ONE_OFF_METHOD_TYPES.has(t));
if (!isOneOff) return ["skip", "first payment used a reusable method"];
if (hasReusable) return ["ok", "a reusable card is already on file"];
const windowDays = subscription.renewal_window_days || RENEWAL_WINDOW_DAYS;
if (daysUntilRenewal > windowDays) return ["skip", "renewal is not close enough yet"];
return ["flag", "one off method with no reusable card before renewal"];
}
async function flagSubscription(subscriptionId, orderId, reason) {
const note = `Renewal risk: ${reason}. The first payment used a one off method ` +
`(iDEAL or Bancontact) and no reusable card is on file. ` +
`Ask the customer to add a card before the next renewal date.`;
await woo(`/orders/${subscriptionId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
if (orderId && orderId !== subscriptionId) {
await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
}
}
export async function run() {
let flagged = 0;
for await (const sub of dueSoonSubscriptions(RENEWAL_WINDOW_DAYS)) {
const parentOrderId = sub.parent_id || sub.id;
const order = await woo(`/orders/${parentOrderId}`);
const intent = order ? await getIntent(intentIdOf(order)) : null;
const customerId = intent ? intent.customer : null;
const reusable = await hasReusableMethod(customerId);
const remaining = daysUntil(sub.next_payment_date_gmt);
const [action, reason] = decide(sub, intent, reusable, remaining);
if (action !== "flag") continue;
console.warn(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
if (!DRY_RUN) await flagSubscription(sub.id, parentOrderId, reason);
flagged++;
}
console.log(`Done. ${flagged} subscription(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
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 which subscriptions get a note and which stay quiet. Because we kept decide pure, the test needs no network, no Stripe account, and no WooCommerce store. It just feeds in plain objects and checks the action.
from check_one_off_methods import decide
def intent(**over):
base = {"payment_method_types": ["ideal"], "customer": "cus_1"}
base.update(over)
return base
def test_flag_when_ideal_and_no_reusable_card_and_renewal_close():
sub = {"renewal_window_days": 7}
assert decide(sub, intent(), False, 3)[0] == "flag"
def test_flag_when_bancontact_and_no_reusable_card_and_renewal_close():
sub = {"renewal_window_days": 7}
assert decide(sub, intent(payment_method_types=["bancontact"]), False, 0)[0] == "flag"
def test_ok_when_reusable_card_already_on_file():
sub = {"renewal_window_days": 7}
assert decide(sub, intent(), True, 3)[0] == "ok"
def test_skip_when_first_payment_was_a_card():
sub = {"renewal_window_days": 7}
assert decide(sub, intent(payment_method_types=["card"]), False, 3)[0] == "skip"
def test_skip_when_renewal_too_far_away():
sub = {"renewal_window_days": 7}
assert decide(sub, intent(), False, 20)[0] == "skip"
def test_skip_when_no_intent_found():
sub = {"renewal_window_days": 7}
assert decide(sub, None, False, 3)[0] == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./check-one-off-methods.js";
const intent = (over = {}) => ({ payment_method_types: ["ideal"], customer: "cus_1", ...over });
test("flag when iDEAL and no reusable card and renewal close", () => {
assert.equal(decide({ renewal_window_days: 7 }, intent(), false, 3)[0], "flag");
});
test("flag when Bancontact and no reusable card and renewal close", () => {
assert.equal(decide({ renewal_window_days: 7 }, intent({ payment_method_types: ["bancontact"] }), false, 0)[0], "flag");
});
test("ok when reusable card already on file", () => {
assert.equal(decide({ renewal_window_days: 7 }, intent(), true, 3)[0], "ok");
});
test("skip when first payment was a card", () => {
assert.equal(decide({ renewal_window_days: 7 }, intent({ payment_method_types: ["card"] }), false, 3)[0], "skip");
});
test("skip when renewal too far away", () => {
assert.equal(decide({ renewal_window_days: 7 }, intent(), false, 20)[0], "skip");
});
test("skip when no intent found", () => {
assert.equal(decide({ renewal_window_days: 7 }, null, false, 3)[0], "skip");
});
Case studies
The store that added iDEAL for a local audience
A store selling a monthly box added iDEAL at checkout to serve Dutch customers, who use it for almost everything online. Within a month, roughly one in six new subscriptions started on iDEAL, and every one of them failed its first renewal with no card to charge.
The scheduled check, run daily with a seven day window, caught each subscription five to seven days before its renewal date. Support emailed each customer with a direct link to add a card, and most of them did before the renewal attempt ever happened.
Bancontact looked identical to a saved method in the admin
A Belgian store noticed subscriptions on Bancontact were quietly piling up on-hold with a generic decline reason, and nothing in the WooCommerce admin explained why, since the order itself clearly showed as paid.
Reading the PaymentIntent directly showed payment_method_types was only ["bancontact"] with no card ever attached to the customer. Once the team understood that, they added the same daily check and stopped the failures from reaching the renewal date at all.
After this runs on a schedule, a subscription that starts on iDEAL or Bancontact is no longer a silent failure waiting to happen. It becomes a short email asking for a card, sent while there is still time to act. Keep it running even after you tighten up checkout messaging, since some customers will always pick the method they know best.
FAQ
Why does a subscription paid with iDEAL or Bancontact fail to renew?
iDEAL and Bancontact are one off, redirect based payment methods. Stripe does not attach a reusable payment method to the customer after either one, so there is nothing on file for WooCommerce Subscriptions to charge automatically when the renewal date arrives.
Can I just retry the same iDEAL or Bancontact payment for the renewal?
No. Both methods need the customer to approve the payment in their banking app or a redirect page each time, so they cannot be charged off session. The only fix is to get a reusable method, usually a card or SEPA Direct Debit, saved on the customer before the next renewal date.
How early should I check for this before a renewal fails?
Five to seven days ahead is a good default. It gives the shop time to email the customer, and the customer time to add a card, before WooCommerce Subscriptions attempts the automatic renewal charge.
Related field notes
Citations
On the problem:
- Stripe docs: iDEAL is a single use, redirect based payment method and is not reusable for future off session payments. docs.stripe.com/payments/ideal
- Stripe docs: Bancontact is a single use, redirect based payment method with the same reuse limitation as iDEAL. docs.stripe.com/payments/bancontact
- WooCommerce Subscriptions docs: automatic renewals require a payment gateway and saved payment method that supports being charged automatically. woocommerce.com/document/subscriptions/renewal-process
On the solution:
- Stripe docs: setting up iDEAL or Bancontact to save a SEPA Direct Debit mandate for reuse, when that flow is configured on purpose. docs.stripe.com/payments/ideal/set-up-payment
- Stripe API: retrieve a PaymentIntent and list a customer's saved payment methods. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: list subscriptions 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 save you a renewal?
If this helped you catch a stalled subscription before it turned into a support ticket, 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