Reconciler WooCommerce Subscriptions: manual renewal and dunning
Renewal actions stall, no renewals made
Subscriptions still show Active. Customers still expect to be billed. But nothing is happening. No renewal orders are appearing, no charges are going out, and the next payment date on every affected subscription just sits there in the past. The scheduled renewal actions have stalled entirely. Here is why that happens and a small script that finds every subscription stuck this way and triggers the renewal it was owed.
WooCommerce Subscriptions renews a subscription by scheduling a woocommerce_scheduled_subscription_payment action in Action Scheduler for the next payment date. When the Action Scheduler queue runner stalls, that action never runs, so no renewal order is created and no charge is attempted. Run a small Python or Node.js script on a schedule that finds active subscriptions whose next payment date has passed with no matching renewal order, then charges the saved payment method with Stripe and creates the renewal order over the WooCommerce REST API, the same way the scheduled action would have. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce Subscriptions does not bill a customer the moment their subscription is due. It schedules a background job ahead of time, and that job is what actually creates the renewal order and asks the payment gateway to charge the card. The job runner behind all of this is Action Scheduler, a queue built into WooCommerce that most stores never think about until it stops moving.
When that queue stalls, every action waiting in it just sits there. Renewal actions pile up as pending, past their scheduled time, and nothing runs them. From the outside, the subscription still says Active and the next payment date is still whatever it was last set to. Nobody gets an error. Nobody gets an email. The store simply stops billing, quietly, for every subscription due after the queue stopped.
Why it happens
Action Scheduler relies on WP-Cron by default, which only fires when a visitor loads a page on the site. A few common ways the queue stops moving:
- WP-Cron is disabled in
wp-config.phpfor performance reasons, and no reliable real cron job was set up to callwp-cron.phpin its place. - A batch of actions got claimed and marked
in-progress, then the PHP process that was running them died or timed out, so the claim is never released and the batch can never be picked up again. - A fatal error inside one hooked callback breaks the whole batch it runs in, so every action after it in that run is left untouched.
- A staging site was cloned from production with its own scheduled actions still queued, and now two sites are fighting to claim the same batch, or the staging site's cron was left disabled entirely.
This has been reported against Action Scheduler directly, where actions can become stuck claimed and in-progress indefinitely after an interrupted run, and it is also documented as a known WooCommerce Subscriptions support scenario when the scheduled payment hook simply stops firing. See the citations at the end for both threads.
The subscription's own record, not the stalled queue, is the source of truth for whether a renewal is owed. If a subscription is active and its next payment date has passed with no renewal order created for that period, the renewal is late, whether or not Action Scheduler ever gets unstuck. A script that reads that state directly and triggers the missing renewal is a safety net underneath the queue, not a replacement for fixing it.
The fix, as a flow
We do not touch the scheduled action or try to repair Action Scheduler's queue from the outside. We add a job that runs on its own schedule, looks at every active subscription whose next payment date is overdue, and checks whether a renewal order already exists for that period. If nothing was created and there is a saved payment method on file, we charge it directly with Stripe and create the renewal order the same way the scheduled action would have.
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 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 GRACE_HOURS="6"
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 GRACE_HOURS="6"
export DRY_RUN="true" // start safe, change to false to write
List active subscriptions that are overdue
Ask the WooCommerce REST API for subscriptions with status active, then keep only the ones whose next_payment_date_gmt is further in the past than a small grace window. The grace window matters, it keeps the script from racing a queue that is only running a little behind, not actually stalled.
import os, time, 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"])
GRACE_HOURS = int(os.environ.get("GRACE_HOURS", "6"))
def stalled_subscriptions():
page = 1
cutoff = int(time.time()) - GRACE_HOURS * 3600
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_ts = _to_epoch(sub.get("next_payment_date_gmt"))
if next_ts is not None and next_ts < 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");
const GRACE_HOURS = Number(process.env.GRACE_HOURS || 6);
async function* stalledSubscriptions() {
const cutoff = Math.floor(Date.now() / 1000) - GRACE_HOURS * 3600;
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
if (!batch.length) return;
for (const sub of batch) {
const nextTs = toEpoch(sub.next_payment_date_gmt);
if (nextTs !== null && nextTs < cutoff) yield sub;
}
page++;
}
}
Check whether the period was already renewed
Read the subscription's own list of renewal order ids and its saved payment method. If a renewal order already exists, the period is covered, maybe a delayed run finally went through, and the script should leave it alone. If there is no saved payment method, charging blind is not an option, that subscription needs a human, not a script.
def last_renewal_order(subscription):
ids = subscription.get("renewal_order_ids") or []
return ids[-1] if ids else None
def payment_method_token_of(subscription):
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_payment_method" and meta.get("value"):
return meta["value"]
return None
export function lastRenewalOrder(subscription) {
const ids = subscription.renewal_order_ids || [];
return ids.length ? ids[ids.length - 1] : null;
}
export function paymentMethodTokenOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_payment_method" && meta.value) return meta.value;
}
return null;
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription plus what we found and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. Skip anything not active or already renewed. Flag anything with no saved payment method. Otherwise, trigger it.
ACTIVE_STATUSES = {"active"}
def decide(subscription, has_recent_renewal_order, payment_method_token):
if subscription.get("status") not in ACTIVE_STATUSES:
return ("skip", "subscription is not active")
if has_recent_renewal_order:
return ("skip", "a renewal order already exists for this period")
if not payment_method_token:
return ("manual", "no saved payment method, needs the customer or manual dunning")
if float(subscription.get("total", "0")) <= 0:
return ("skip", "zero cost renewal, no charge needed")
return ("trigger", "next payment date passed with no renewal order or charge")
const ACTIVE_STATUSES = new Set(["active"]);
export function decide(subscription, hasRecentRenewalOrder, paymentMethodToken) {
if (!ACTIVE_STATUSES.has(subscription.status)) return ["skip", "subscription is not active"];
if (hasRecentRenewalOrder) return ["skip", "a renewal order already exists for this period"];
if (!paymentMethodToken) return ["manual", "no saved payment method, needs the customer or manual dunning"];
if (parseFloat(subscription.total || "0") <= 0) return ["skip", "zero cost renewal, no charge needed"];
return ["trigger", "next payment date passed with no renewal order or charge"];
}
Charge off session and create the renewal order
When the action is trigger, charge the saved Stripe payment method off session for the subscription's total, then create the renewal order over the WooCommerce REST API and mark it processing with the charge as the transaction ID. Add an order note so the shop manager can see it was created by the recovery script and why.
def charge_off_session(customer_id, payment_method_token, amount_minor, currency, subscription_id):
return stripe.PaymentIntent.create(
amount=amount_minor,
currency=currency,
customer=customer_id,
payment_method=payment_method_token,
off_session=True,
confirm=True,
metadata={"subscription_id": str(subscription_id), "reason": "stalled_renewal_trigger"},
)
def create_renewal_order(subscription, intent):
charge_id = intent.get("latest_charge") or intent["id"]
r = requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders",
json={
"status": "processing",
"customer_id": subscription["customer_id"],
"payment_method": subscription.get("payment_method", "stripe"),
"transaction_id": charge_id,
"line_items": subscription.get("line_items", []),
"meta_data": [{"key": "_subscription_renewal", "value": str(subscription["id"])}],
},
auth=AUTH, timeout=30,
)
r.raise_for_status()
order = r.json()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Renewal triggered manually after the scheduled Action Scheduler "
f"action stalled. Charged Stripe PaymentIntent {intent['id']}."},
auth=AUTH, timeout=30,
).raise_for_status()
return order
async function chargeOffSession(customerId, paymentMethodToken, amountMinor, currency, subscriptionId) {
return stripe.paymentIntents.create({
amount: amountMinor,
currency,
customer: customerId,
payment_method: paymentMethodToken,
off_session: true,
confirm: true,
metadata: { subscription_id: String(subscriptionId), reason: "stalled_renewal_trigger" },
});
}
async function createRenewalOrder(subscription, intent) {
const chargeId = intent.latest_charge || intent.id;
const order = await woo("/orders", {
method: "POST",
body: JSON.stringify({
status: "processing",
customer_id: subscription.customer_id,
payment_method: subscription.payment_method || "stripe",
transaction_id: chargeId,
line_items: subscription.line_items || [],
meta_data: [{ key: "_subscription_renewal", value: String(subscription.id) }],
}),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Renewal triggered manually after the scheduled Action Scheduler action ` +
`stalled. Charged Stripe PaymentIntent ${intent.id}.`,
}),
});
return order;
}
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 trigger. Read the output, trust it, then switch it off to let it charge. Run it on a schedule with cron every few hours, alongside fixing the actual Action Scheduler stall.
Always start with DRY_RUN=true. This script charges real cards, so you want to see its plan before it acts. Once the report looks right, turn it off, and keep it running only until the underlying Action Scheduler stall is actually fixed.
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 never charges a subscription that already has a renewal order or has no saved payment method.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Detect and trigger WooCommerce Subscriptions renewals whose scheduled Action
Scheduler action stalled and never ran.
WooCommerce Subscriptions renews a subscription by scheduling a
"woocommerce_scheduled_subscription_payment" action in Action Scheduler for the
subscription's next payment date. If the Action Scheduler queue runner stalls
(WP-Cron disabled, a stuck "in-progress" claim, PHP timing out mid batch), that
action never fires. The subscription stays active, its next payment date drifts
into the past, and no renewal order and no charge are ever created.
This script finds active subscriptions whose next payment date has passed with no
matching renewal order, and for each one, charges the customer's saved payment
method off session with Stripe and creates the renewal order over the WooCommerce
REST API, the same way the scheduled action would have. Read only by default.
"""
import os
import time
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("trigger_stalled_renewals")
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"])
GRACE_HOURS = int(os.environ.get("GRACE_HOURS", "6"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ACTIVE_STATUSES = {"active"}
def stalled_subscriptions():
"""Yield active subscriptions whose next payment date is in the past
by more than GRACE_HOURS, from the WooCommerce Subscriptions REST API."""
page = 1
cutoff = int(time.time()) - GRACE_HOURS * 3600
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_ts = _to_epoch(sub.get("next_payment_date_gmt"))
if next_ts is not None and next_ts < cutoff:
yield sub
page += 1
def _to_epoch(gmt_string):
if not gmt_string:
return None
try:
return int(time.mktime(time.strptime(gmt_string, "%Y-%m-%dT%H:%M:%S")))
except ValueError:
return None
def last_renewal_order(subscription):
"""The most recent renewal order id linked to this subscription, or None."""
ids = subscription.get("renewal_order_ids") or []
return ids[-1] if ids else None
def payment_method_token_of(subscription):
"""The saved Stripe payment method id, from meta _stripe_payment_method.
Returns None when nothing is saved."""
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_payment_method" and meta.get("value"):
return meta["value"]
return None
def subscription_amount_minor(subscription):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# have their own guide, since 50.00 is wrong for those.
return round(float(subscription["total"]) * 100)
def decide(subscription, has_recent_renewal_order, payment_method_token):
"""Pure decision: what should happen to one stalled subscription.
Returns a tuple of (action, reason). No I/O happens here, which is what
makes it safe and fast to unit test.
"""
if subscription.get("status") not in ACTIVE_STATUSES:
return ("skip", "subscription is not active")
if has_recent_renewal_order:
return ("skip", "a renewal order already exists for this period")
if not payment_method_token:
return ("manual", "no saved payment method, needs the customer or manual dunning")
if float(subscription.get("total", "0")) <= 0:
return ("skip", "zero cost renewal, no charge needed")
return ("trigger", "next payment date passed with no renewal order or charge")
def charge_off_session(customer_id, payment_method_token, amount_minor, currency, subscription_id):
intent = stripe.PaymentIntent.create(
amount=amount_minor,
currency=currency,
customer=customer_id,
payment_method=payment_method_token,
off_session=True,
confirm=True,
metadata={"subscription_id": str(subscription_id), "reason": "stalled_renewal_trigger"},
)
return intent
def create_renewal_order(subscription, intent):
charge_id = intent.get("latest_charge") or intent["id"]
r = requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders",
json={
"status": "processing",
"customer_id": subscription["customer_id"],
"payment_method": subscription.get("payment_method", "stripe"),
"transaction_id": charge_id,
"line_items": subscription.get("line_items", []),
"meta_data": [{"key": "_subscription_renewal", "value": str(subscription["id"])}],
},
auth=AUTH, timeout=30,
)
r.raise_for_status()
order = r.json()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"Renewal triggered manually after the scheduled Action Scheduler "
f"action stalled. Charged Stripe PaymentIntent {intent['id']}."},
auth=AUTH, timeout=30,
).raise_for_status()
return order
def run():
triggered = 0
for subscription in stalled_subscriptions():
renewal_order_id = last_renewal_order(subscription)
payment_method_token = payment_method_token_of(subscription)
action, reason = decide(subscription, renewal_order_id is not None, payment_method_token)
if action == "skip":
continue
if action == "manual":
log.warning("Subscription %s: %s", subscription["id"], reason)
continue
log.info("Subscription %s: %s. %s", subscription["id"], reason, "would trigger" if DRY_RUN else "triggering")
if not DRY_RUN:
intent = charge_off_session(
subscription["customer_id"],
payment_method_token,
subscription_amount_minor(subscription),
subscription.get("currency", "usd").lower(),
subscription["id"],
)
create_renewal_order(subscription, intent)
triggered += 1
log.info("Done. %d subscription(s) %s.", triggered, "to trigger" if DRY_RUN else "triggered")
if __name__ == "__main__":
run()
/**
* Detect and trigger WooCommerce Subscriptions renewals whose scheduled Action
* Scheduler action stalled and never ran.
*
* WooCommerce Subscriptions renews a subscription by scheduling a
* "woocommerce_scheduled_subscription_payment" action in Action Scheduler for the
* subscription's next payment date. If the Action Scheduler queue runner stalls
* (WP-Cron disabled, a stuck "in-progress" claim, PHP timing out mid batch), that
* action never fires. The subscription stays active, its next payment date drifts
* into the past, and no renewal order and no charge are ever created.
*
* This script finds active subscriptions whose next payment date has passed with
* no matching renewal order, and for each one, charges the customer's saved
* payment method off session with Stripe and creates the renewal order over the
* WooCommerce REST API, the same way the scheduled action would have. Read only
* by default.
*
* Guide: https://www.allanninal.dev/woocommerce/renewal-actions-stall-no-renewals-made/
*/
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 GRACE_HOURS = Number(process.env.GRACE_HOURS || 6);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ACTIVE_STATUSES = new Set(["active"]);
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();
}
function toEpoch(gmtString) {
if (!gmtString) return null;
const ms = Date.parse(gmtString.endsWith("Z") ? gmtString : `${gmtString}Z`);
return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
}
async function* stalledSubscriptions() {
const cutoff = Math.floor(Date.now() / 1000) - GRACE_HOURS * 3600;
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
if (!batch.length) return;
for (const sub of batch) {
const nextTs = toEpoch(sub.next_payment_date_gmt);
if (nextTs !== null && nextTs < cutoff) yield sub;
}
page++;
}
}
export function lastRenewalOrder(subscription) {
const ids = subscription.renewal_order_ids || [];
return ids.length ? ids[ids.length - 1] : null;
}
export function paymentMethodTokenOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_payment_method" && meta.value) return meta.value;
}
return null;
}
export function subscriptionAmountMinor(subscription) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// have their own guide, since 50.00 is wrong for those.
return Math.round(parseFloat(subscription.total) * 100);
}
/**
* Pure decision: what should happen to one stalled subscription.
* Returns [action, reason]. No I/O happens here, which is what makes it safe
* and fast to unit test.
*/
export function decide(subscription, hasRecentRenewalOrder, paymentMethodToken) {
if (!ACTIVE_STATUSES.has(subscription.status)) return ["skip", "subscription is not active"];
if (hasRecentRenewalOrder) return ["skip", "a renewal order already exists for this period"];
if (!paymentMethodToken) return ["manual", "no saved payment method, needs the customer or manual dunning"];
if (parseFloat(subscription.total || "0") <= 0) return ["skip", "zero cost renewal, no charge needed"];
return ["trigger", "next payment date passed with no renewal order or charge"];
}
async function chargeOffSession(customerId, paymentMethodToken, amountMinor, currency, subscriptionId) {
return stripe.paymentIntents.create({
amount: amountMinor,
currency,
customer: customerId,
payment_method: paymentMethodToken,
off_session: true,
confirm: true,
metadata: { subscription_id: String(subscriptionId), reason: "stalled_renewal_trigger" },
});
}
async function createRenewalOrder(subscription, intent) {
const chargeId = intent.latest_charge || intent.id;
const order = await woo("/orders", {
method: "POST",
body: JSON.stringify({
status: "processing",
customer_id: subscription.customer_id,
payment_method: subscription.payment_method || "stripe",
transaction_id: chargeId,
line_items: subscription.line_items || [],
meta_data: [{ key: "_subscription_renewal", value: String(subscription.id) }],
}),
});
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Renewal triggered manually after the scheduled Action Scheduler action ` +
`stalled. Charged Stripe PaymentIntent ${intent.id}.`,
}),
});
return order;
}
export async function run() {
let triggered = 0;
for await (const subscription of stalledSubscriptions()) {
const renewalOrderId = lastRenewalOrder(subscription);
const paymentMethodToken = paymentMethodTokenOf(subscription);
const [action, reason] = decide(subscription, renewalOrderId !== null, paymentMethodToken);
if (action === "skip") continue;
if (action === "manual") {
console.warn(`Subscription ${subscription.id}: ${reason}`);
continue;
}
console.log(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would trigger" : "triggering"}`);
if (!DRY_RUN) {
const intent = await chargeOffSession(
subscription.customer_id,
paymentMethodToken,
subscriptionAmountMinor(subscription),
(subscription.currency || "usd").toLowerCase(),
subscription.id
);
await createRenewalOrder(subscription, intent);
}
triggered++;
}
console.log(`Done. ${triggered} subscription(s) ${DRY_RUN ? "to trigger" : "triggered"}.`);
}
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 real cards get charged. 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 trigger_stalled_renewals import decide
def sub(**over):
base = {"status": "active", "total": "29.00"}
base.update(over)
return base
def test_trigger_when_due_and_no_renewal_order():
assert decide(sub(), False, "pm_1")[0] == "trigger"
def test_skip_when_renewal_order_already_exists():
assert decide(sub(), True, "pm_1")[0] == "skip"
def test_skip_when_subscription_not_active():
assert decide(sub(status="on-hold"), False, "pm_1")[0] == "skip"
def test_manual_when_no_payment_method_saved():
assert decide(sub(), False, None)[0] == "manual"
def test_skip_when_zero_cost_renewal():
assert decide(sub(total="0.00"), False, "pm_1")[0] == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, lastRenewalOrder, paymentMethodTokenOf, subscriptionAmountMinor } from "./trigger-stalled-renewals.js";
const sub = (over = {}) => ({ status: "active", total: "29.00", ...over });
test("trigger when due and no renewal order", () => {
assert.equal(decide(sub(), false, "pm_1")[0], "trigger");
});
test("skip when renewal order already exists", () => {
assert.equal(decide(sub(), true, "pm_1")[0], "skip");
});
test("skip when subscription not active", () => {
assert.equal(decide(sub({ status: "on-hold" }), false, "pm_1")[0], "skip");
});
test("manual when no payment method saved", () => {
assert.equal(decide(sub(), false, null)[0], "manual");
});
test("skip when zero cost renewal", () => {
assert.equal(decide(sub({ total: "0.00" }), false, "pm_1")[0], "skip");
});
Case studies
The host that turned off WP-Cron for speed
A managed host disabled WP-Cron and set up a real system cron job to call wp-cron.php, but the job pointed at the staging URL by mistake after a migration. On production, nothing was ever calling into WordPress to run scheduled actions, so weeks of renewals piled up silently while every subscription still showed Active.
The script found over three hundred overdue subscriptions on its first dry run. The team fixed the cron job, then ran the script for real in small batches to catch up the backlog without a spike of chargebacks from stale cards.
The batch that never let go
A plugin conflict caused a PHP fatal error mid way through an Action Scheduler batch. The batch stayed claimed and in-progress, and because Action Scheduler will not reclaim a batch that is still marked as running, every renewal action queued after that point simply waited forever.
The recovery script kept the store billing correctly while the team traced the fatal error to the conflicting plugin, deactivated it, and cleared the stuck batch by hand.
After this runs on a schedule, a stalled queue stops meaning lost revenue. The worst case becomes a delay of a few hours before the script catches an overdue subscription and charges it the same way the scheduled action would have. Keep it running as backup coverage even after Action Scheduler is healthy again, and treat any subscription it flags as manual as a real support task.
FAQ
Why did my WooCommerce Subscriptions renewals stop running?
WooCommerce Subscriptions schedules each renewal as a woocommerce_scheduled_subscription_payment action in Action Scheduler. If the queue runner stalls, because WP-Cron is disabled, a batch got stuck in-progress, or a fatal error broke a run, those actions never fire. A script that finds active subscriptions whose next payment date has passed with no matching renewal order, then triggers the charge directly, fixes it.
Is it safe to charge a customer with a script instead of letting WooCommerce do it?
Yes, when the script only acts on subscriptions that are active, past their next payment date, have no renewal order for the current period yet, and have a saved payment method on file. It charges off session with Stripe the same way the scheduled action would have. Start in dry run mode to review the list before it charges anyone.
What happens to subscriptions with no saved payment method?
The script never guesses. When there is no saved payment method it flags the subscription for manual follow up instead of attempting a charge, so a missing card becomes a support task rather than a silent failure.
Related field notes
Citations
On the problem:
- Action Scheduler issue: actions can be left claimed and in-progress indefinitely after an interrupted run. github.com/woocommerce/action-scheduler/issues/653
- WooCommerce docs: how Action Scheduler processes scheduled tasks and depends on WP-Cron or a real server cron. actionscheduler.org/faq
- WooCommerce Subscriptions docs: renewal processing and the scheduled payment hook. woocommerce.com/document/subscriptions/renewal-process
On the solution:
- Stripe docs: creating and confirming off session PaymentIntents for saved payment methods. docs.stripe.com/payments/save-and-reuse
- WooCommerce REST API: list and read subscriptions. woocommerce.github.io/subscriptions-rest-api-docs
- WooCommerce REST API: create 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 get your renewals moving again?
If this saved you a pile of missed renewals or an awkward billing gap conversation, 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