Repair Subscription lifecycle
SEPA renewal stays active on fail
A subscription renewed with SEPA Direct Debit. Stripe said the payment was on its way, WooCommerce marked the renewal Completed, and the subscription stayed Active. Then, more than a week later, the customer's bank returned the debit as failed. Nobody noticed, because that second event never reached the store. The subscription is still active, the customer is still using what they did not pay for, and dunning never started. Here is why the delayed failure gets missed and a small script that catches it and moves the order to On hold so retries can run.
SEPA Direct Debit confirms in two steps. Stripe marks the PaymentIntent processing right away, so the renewal order gets marked paid and the subscription stays active, but the actual bank debit can still fail up to fourteen days later. If that later failure webhook is lost, nothing in WooCommerce ever hears about it. Run a small Python or Node.js repair script on a schedule that reads recent SEPA renewal orders, checks the PaymentIntent status straight from Stripe, and moves any order whose payment truly failed to On hold, which is the status WooCommerce Subscriptions needs to start dunning. Full code, tests, and a dry run guard are below.
The problem in plain words
Most card payments settle in a second or two. SEPA Direct Debit does not work that way. When a customer approves a SEPA mandate, Stripe creates a PaymentIntent and reports it as processing immediately, well before the money has actually moved. Plenty of stores treat processing as good enough and mark the renewal order Completed on the spot, because for cards that assumption is usually safe.
For SEPA it is not. The debit is sent to the customer's bank and the bank has a window, commonly two to fourteen business days, to return it unpaid for reasons like insufficient funds, a closed account, or the mandate being disputed. When that happens, Stripe fires a second event days after the first one, and the PaymentIntent status finally changes to a real failure. If that second webhook is blocked, mistimed, or simply never wired up because the first success looked final, the subscription and the renewal order never learn the truth. They sit there looking paid while the store was never actually paid.
Why it happens
Stripe's own docs are direct about this: SEPA Direct Debit is an asynchronous payment method, and a PaymentIntent showing processing is not a guarantee of funds. WooCommerce Subscriptions and the WooCommerce Stripe gateway generally handle the eventual success or failure correctly when the webhook arrives, but a few things get in the way of that second event:
- The store, or a proxy, firewall, or security plugin in front of it, treats every
payment_intentevent the same and only listens forpayment_intent.succeeded, sopayment_intent.payment_failedis never wired up to update the subscription. - The webhook endpoint was briefly down or returned an error on the day the delayed failure came in, and by the time anyone noticed, Stripe's retry window for that event had passed.
- A staging to production migration, or a rotated webhook signing secret, silently broke delivery for events that happen to arrive days after the mandate was charged, while the immediate ones kept working.
- The renewal order's stored PaymentIntent id was never saved correctly in the first place, so even a delivered webhook has nothing to match against. That case has its own guide.
The pattern shows up often enough in WooCommerce Subscriptions support threads that it has a name among the support team: the "SEPA ghost renewal," a subscription that renews clean on the surface and quietly fails to actually get paid. See the citations at the end for where this is documented.
For SEPA, processing is not the end of the story, only Stripe's final PaymentIntent status is. A repair script does not need to guess. It rereads the PaymentIntent for every renewal that still looks active, and if Stripe now shows a real failure that WooCommerce never applied, the script is the second chance the missed webhook should have been.
The fix, as a flow
We do not change how renewals are charged. We add a job that runs on a schedule, looks at recent SEPA renewal orders that are still marked paid, and asks Stripe directly what the PaymentIntent status is today rather than trusting what was recorded at the time. If Stripe now reports a real failure and the order has not already been put on hold for it, we move the order to On hold and add a note, exactly what the missed webhook should have done.
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="14"
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="14"
export DRY_RUN="true" // start safe, change to false to write
List recent paid renewal orders
Ask the WooCommerce REST API for renewal orders in a paid state, going back as far as SEPA's return window, fourteen days is a safe default. We page through all of them, since this is the only place that reliably tells us which orders are subscription renewals.
import os, datetime, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
PAID_STATUSES = {"processing", "completed"}
def paid_renewal_orders(lookback_days):
after = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat() + "T00:00:00"
page = 1
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:
if any(m.get("key") == "_subscription_renewal" for m in order.get("meta_data") or []):
yield order
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 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* paidRenewalOrders(lookbackDays) {
const after = new Date(Date.now() - lookbackDays * 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) {
if ((order.meta_data || []).some((m) => m.key === "_subscription_renewal")) yield order;
}
page++;
}
}
Read the live PaymentIntent from Stripe
The renewal order carries the PaymentIntent id in meta _stripe_intent_id, or in transaction_id for older orders. Retrieve it fresh from Stripe rather than trusting whatever status was recorded when the order was created, since that is exactly the value SEPA can change days later.
import stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
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 get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
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;
}
}
Decide, with one pure function
Keep the decision in its own function that takes an order and a PaymentIntent and returns an action. SEPA has a real "still working" state, so the rule has to tell a genuine failure apart from a payment that is simply still processing. Only requires_payment_method and canceled count as a real failure here. Anything still processing is left alone, and anything already succeeded is left alone too.
PAID_STATUSES = {"processing", "completed"}
FAILED_INTENT_STATUSES = {"requires_payment_method", "canceled"}
ALREADY_HANDLED_STATUSES = {"on-hold", "failed"}
def decide(order, intent):
if order["status"] in ALREADY_HANDLED_STATUSES:
return ("skip", "already moved off active")
if order["status"] not in PAID_STATUSES:
return ("skip", "order not in a paid state")
if intent is None:
return ("skip", "no PaymentIntent to check")
if intent.get("status") == "succeeded":
return ("skip", "Stripe confirms the payment succeeded")
if intent.get("status") not in FAILED_INTENT_STATUSES:
return ("wait", "SEPA still processing, not a failure yet")
return ("repair", "SEPA mandate failed after the renewal was marked paid")
const PAID_STATUSES = new Set(["processing", "completed"]);
const FAILED_INTENT_STATUSES = new Set(["requires_payment_method", "canceled"]);
const ALREADY_HANDLED_STATUSES = new Set(["on-hold", "failed"]);
export function decide(order, intent) {
if (ALREADY_HANDLED_STATUSES.has(order.status)) return ["skip", "already moved off active"];
if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
if (!intent) return ["skip", "no PaymentIntent to check"];
if (intent.status === "succeeded") return ["skip", "Stripe confirms the payment succeeded"];
if (!FAILED_INTENT_STATUSES.has(intent.status)) return ["wait", "SEPA still processing, not a failure yet"];
return ["repair", "SEPA mandate failed after the renewal was marked paid"];
}
Move the order to On hold and let dunning run
When the action is repair, set the renewal order to on-hold, not straight to failed. WooCommerce Subscriptions treats a renewal order moving to On hold as the trigger to start its automatic payment retry schedule and reminder emails, the same path a delivered payment_intent.payment_failed webhook would have taken. Add an order note so the shop manager can see why it moved.
def mark_on_hold(order_id, intent):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"status": "on-hold"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"SEPA mandate failed after this renewal was marked paid. "
f"Stripe PaymentIntent {intent['id']} now shows {intent['status']}. "
f"Moved to on-hold so payment retries can run."},
auth=AUTH, timeout=30,
).raise_for_status()
async function markOnHold(orderId, intent) {
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({ status: "on-hold" }),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `SEPA mandate failed after this renewal was marked paid. ` +
`Stripe PaymentIntent ${intent.id} now shows ${intent.status}. ` +
`Moved to on-hold so payment retries can run.`,
}),
});
}
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 logs what it would repair. Read the output, confirm it against Stripe's own dashboard, then switch it off. Run it once a day with cron, since SEPA's return window is measured in days, not minutes.
Always start with DRY_RUN=true. Moving a live subscription to On hold affects a real customer and starts real dunning emails, so read the plan before you let the script act on it.
The full code
Here is the complete repair script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches an order that already succeeded or was already moved off active.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Move SEPA renewal orders to on-hold when the mandate failed after the fact.
SEPA Direct Debit reports a PaymentIntent as processing right away, so WooCommerce
marks the renewal paid before the bank has actually confirmed the debit. If the
bank later returns it unpaid and that webhook is missed, the renewal order and the
subscription stay active with no real payment behind them. This walks recent paid
renewal orders, rereads the PaymentIntent status straight from Stripe, and moves any
order whose SEPA mandate truly failed to on-hold so dunning can run. Safe to run
again and again. Run on a schedule.
"""
import os
import datetime
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("repair_sepa_renewal")
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", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAID_STATUSES = {"processing", "completed"}
FAILED_INTENT_STATUSES = {"requires_payment_method", "canceled"}
ALREADY_HANDLED_STATUSES = {"on-hold", "failed"}
def paid_renewal_orders(lookback_days):
after = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat() + "T00:00:00"
page = 1
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:
if any(m.get("key") == "_subscription_renewal" for m in order.get("meta_data") or []):
yield order
page += 1
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 get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def decide(order, intent):
if order["status"] in ALREADY_HANDLED_STATUSES:
return ("skip", "already moved off active")
if order["status"] not in PAID_STATUSES:
return ("skip", "order not in a paid state")
if intent is None:
return ("skip", "no PaymentIntent to check")
if intent.get("status") == "succeeded":
return ("skip", "Stripe confirms the payment succeeded")
if intent.get("status") not in FAILED_INTENT_STATUSES:
return ("wait", "SEPA still processing, not a failure yet")
return ("repair", "SEPA mandate failed after the renewal was marked paid")
def mark_on_hold(order_id, intent):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"status": "on-hold"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"SEPA mandate failed after this renewal was marked paid. "
f"Stripe PaymentIntent {intent['id']} now shows {intent['status']}. "
f"Moved to on-hold so payment retries can run."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
repaired = 0
for order in paid_renewal_orders(LOOKBACK_DAYS):
intent = get_intent(intent_id_of(order))
action, reason = decide(order, intent)
if action != "repair":
if action == "wait":
log.info("Order %s: %s", order["id"], reason)
continue
log.warning("Order %s: %s. %s", order["id"], reason, "would repair" if DRY_RUN else "repairing")
if not DRY_RUN:
mark_on_hold(order["id"], intent)
repaired += 1
log.info("Done. %d order(s) %s.", repaired, "to repair" if DRY_RUN else "repaired")
if __name__ == "__main__":
run()
/**
* Move SEPA renewal orders to on-hold when the mandate failed after the fact.
*
* SEPA Direct Debit reports a PaymentIntent as processing right away, so WooCommerce
* marks the renewal paid before the bank has actually confirmed the debit. If the
* bank later returns it unpaid and that webhook is missed, the renewal order and the
* subscription stay active with no real payment behind them. This walks recent paid
* renewal orders, rereads the PaymentIntent status straight from Stripe, and moves any
* order whose SEPA mandate truly failed to on-hold so dunning can run. Safe to run
* again and again. 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 || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAID_STATUSES = new Set(["processing", "completed"]);
const FAILED_INTENT_STATUSES = new Set(["requires_payment_method", "canceled"]);
const ALREADY_HANDLED_STATUSES = new Set(["on-hold", "failed"]);
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* paidRenewalOrders(lookbackDays) {
const after = new Date(Date.now() - lookbackDays * 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) {
if ((order.meta_data || []).some((m) => m.key === "_subscription_renewal")) yield order;
}
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);
} catch {
return null;
}
}
export function decide(order, intent) {
if (ALREADY_HANDLED_STATUSES.has(order.status)) return ["skip", "already moved off active"];
if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
if (!intent) return ["skip", "no PaymentIntent to check"];
if (intent.status === "succeeded") return ["skip", "Stripe confirms the payment succeeded"];
if (!FAILED_INTENT_STATUSES.has(intent.status)) return ["wait", "SEPA still processing, not a failure yet"];
return ["repair", "SEPA mandate failed after the renewal was marked paid"];
}
async function markOnHold(orderId, intent) {
await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `SEPA mandate failed after this renewal was marked paid. ` +
`Stripe PaymentIntent ${intent.id} now shows ${intent.status}. ` +
`Moved to on-hold so payment retries can run.`,
}),
});
}
export async function run() {
let repaired = 0;
for await (const order of paidRenewalOrders(LOOKBACK_DAYS)) {
const intent = await getIntent(intentIdOf(order));
const [action, reason] = decide(order, intent);
if (action !== "repair") {
if (action === "wait") console.log(`Order ${order.id}: ${reason}`);
continue;
}
console.warn(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
if (!DRY_RUN) await markOnHold(order.id, intent);
repaired++;
}
console.log(`Done. ${repaired} order(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}
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 live subscription gets pulled from Active. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action, including the case that trips up a naive version of this fix: a SEPA payment that is still processing and must not be touched.
from repair_sepa_renewal import decide, intent_id_of
def intent(**over):
base = {"status": "requires_payment_method", "id": "pi_1"}
base.update(over)
return base
def test_repair_when_mandate_failed_on_paid_order():
order = {"status": "processing"}
assert decide(order, intent())[0] == "repair"
def test_repair_when_mandate_canceled():
order = {"status": "completed"}
assert decide(order, intent(status="canceled"))[0] == "repair"
def test_wait_when_still_processing():
order = {"status": "processing"}
assert decide(order, intent(status="processing"))[0] == "wait"
def test_skip_when_succeeded():
order = {"status": "processing"}
assert decide(order, intent(status="succeeded"))[0] == "skip"
def test_skip_when_already_on_hold():
order = {"status": "on-hold"}
assert decide(order, intent())[0] == "skip"
def test_skip_when_order_not_paid():
order = {"status": "pending"}
assert decide(order, intent())[0] == "skip"
def test_skip_when_no_intent():
order = {"status": "processing"}
assert decide(order, None)[0] == "skip"
def test_intent_id_from_meta():
order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
assert intent_id_of(order) == "pi_123"
def test_intent_id_falls_back_to_transaction_id():
order = {"meta_data": [], "transaction_id": "pi_456"}
assert intent_id_of(order) == "pi_456"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./repair-sepa-renewal.js";
const intent = (over = {}) => ({ status: "requires_payment_method", id: "pi_1", ...over });
test("repair when mandate failed on paid order", () => {
assert.equal(decide({ status: "processing" }, intent())[0], "repair");
});
test("repair when mandate canceled", () => {
assert.equal(decide({ status: "completed" }, intent({ status: "canceled" }))[0], "repair");
});
test("wait when still processing", () => {
assert.equal(decide({ status: "processing" }, intent({ status: "processing" }))[0], "wait");
});
test("skip when succeeded", () => {
assert.equal(decide({ status: "processing" }, intent({ status: "succeeded" }))[0], "skip");
});
test("skip when already on hold", () => {
assert.equal(decide({ status: "on-hold" }, intent())[0], "skip");
});
test("skip when order not paid", () => {
assert.equal(decide({ status: "pending" }, intent())[0], "skip");
});
test("skip when no intent", () => {
assert.equal(decide({ status: "processing" }, null)[0], "skip");
});
test("intentIdOf from meta", () => {
assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});
test("intentIdOf falls back to transaction_id", () => {
assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});
Case studies
The subscriber who kept access for a week and a half
A membership site in the Netherlands only listened for payment_intent.succeeded on its webhook endpoint. A renewal charged by SEPA looked paid the moment the mandate was submitted. Ten days later the bank returned it for insufficient funds, but with no handler for the failure event, the subscription stayed active and the member kept full access.
Running the repair script on a daily schedule caught it on its first pass, since Stripe's own PaymentIntent already showed requires_payment_method. The order moved to on-hold, dunning emails went out, and the member's card on file cleared the balance two days later.
The rotation that broke only the slow events
A store rotated its Stripe webhook signing secret during a routine security review and updated the config everywhere except one background worker that also verified signatures. Card payments still worked because most of that traffic went through the primary endpoint, but delayed SEPA failure events routed to the stale worker and were silently rejected.
The team ran the repair script in dry run first, saw eleven affected renewals accumulated over three weeks, confirmed each one against the Stripe dashboard, then let it write. All eleven moved to on-hold and dunning caught up within a day.
After this runs on a schedule, a missed SEPA failure webhook stops meaning a free renewal. The worst case becomes a delay of up to a day before the subscription is correctly moved to on-hold and dunning takes over. Keep it running even after the webhook gap is fixed, because SEPA's own delayed nature means this will always be a risk worth watching.
FAQ
Why does a subscription stay active after a SEPA renewal fails?
SEPA Direct Debit confirms in two steps. Stripe reports the PaymentIntent as processing right away, so WooCommerce marks the renewal paid and keeps the subscription active. The bank can still return the debit as failed two to fourteen days later. If that later webhook is missed, the subscription never learns the payment actually failed.
Is it safe to move a subscription to On hold with a script?
Yes, when the script confirms Stripe shows the PaymentIntent status as a real failure, not still processing, and it only acts on renewal orders that are currently marked paid. Start in dry run mode to review the exact list before it writes anything.
Why move the order to On hold instead of Failed?
On hold is the status WooCommerce Subscriptions watches to run its automatic payment retry schedule, known as dunning. Moving straight to Failed skips that retry schedule and the reminder emails that come with it.
Related field notes
Citations
On the problem:
- Stripe docs: SEPA Direct Debit payments are asynchronous, a PaymentIntent can report processing before the debit is confirmed by the bank. docs.stripe.com/payments/sepa-debit
- Stripe docs: handling delayed notification payment methods and the events they fire after the fact. docs.stripe.com/payments/payment-methods/payment-notification-events
- WooCommerce Subscriptions docs: how renewal payments and automatic status changes are expected to work. woocommerce.com/document/subscriptions/renewal-process
On the solution:
- WooCommerce Subscriptions docs: renewal payment retry, also called dunning, and which order status starts it. woocommerce.com/document/subscriptions/renewal-process
- Stripe API: retrieve a PaymentIntent to read its current status directly rather than trusting a stored value. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: update an order status 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 ghost renewals?
If this saved you from an unpaid subscriber quietly keeping access, 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