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.

Python and Node.js Runs on a schedule Safe by default (dry run)
Card payment terminal
Photo by Clay Banks on Unsplash
The short answer

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.

Renewal due SEPA mandate charged Stripe: processing Order marked paid now Subscription active 2 to 14 days pass late webhook lost Bank returns debit unpaid PaymentIntent: requires_payment_method Store never told order and subscription unchanged No dunning customer keeps access unpaid
The order looks settled the moment the mandate is charged. The real failure lands days later on a webhook that gets missed, so the subscription never moves and dunning never starts.

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 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.

The key insight

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.

Scheduled job once a day List paid SEPA renewal orders (last 14d) Read PaymentIntent id from order meta Stripe shows real failure? yes no, skip Mark On hold add note, dunning starts
The repair reads the live truth from Stripe and only touches renewal orders that are still marked paid while the actual debit failed. Everything else is left alone.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

step2.py
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
step2.js
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++;
  }
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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")
decide.js
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"];
}
5

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.

apply.py
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()
apply.js
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.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only 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.

Run it safe

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.

repair_sepa_renewal.py
"""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()
repair-sepa-renewal.js
/**
 * 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.

test_sepa_decide.py
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"
sepa-decide.test.js
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

Insufficient funds, ten days late

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.

Webhook secret rotation

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.

What good looks like

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:

  1. 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
  2. Stripe docs: handling delayed notification payment methods and the events they fire after the fact. docs.stripe.com/payments/payment-methods/payment-notification-events
  3. WooCommerce Subscriptions docs: how renewal payments and automatic status changes are expected to work. woocommerce.com/document/subscriptions/renewal-process

On the solution:

  1. WooCommerce Subscriptions docs: renewal payment retry, also called dunning, and which order status starts it. woocommerce.com/document/subscriptions/renewal-process
  2. Stripe API: retrieve a PaymentIntent to read its current status directly rather than trusting a stored value. docs.stripe.com/api/payment_intents/retrieve
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes