Reconciler WooCommerce Subscriptions: status and renewals
Stuck in pending-cancel
A customer cancelled weeks ago. The subscription still shows status pending-cancel, it never moved to cancelled, and it is still sitting in every report as if it might come back. Nobody is being charged, but nothing looks finished either. pending-cancel is supposed to be a short wait for the current paid period to end, not a permanent state. Here is why it gets stuck and a small script that finds every subscription like this and finishes the cancellation safely.
pending-cancel means "the customer cancelled, but let the paid period finish first." WooCommerce Subscriptions schedules an Action Scheduler action for the subscription's end date, and that action is what actually flips the status to cancelled. If that one scheduled action never runs, the subscription just sits in pending-cancel with no end ever applied. Run a small Python or Node.js reconciler on a schedule that lists subscriptions with status pending-cancel from the WooCommerce REST API, checks whether the end date has already passed, confirms with Stripe that the subscription is not still actively billing, and moves it to cancelled. Full code, tests, and a dry run guard are below.
The problem in plain words
When a customer cancels a WooCommerce subscription, the store usually does not want to cut them off mid period if they already paid for it. So WooCommerce Subscriptions puts the subscription into pending-cancel instead of cancelled right away. The customer keeps access until the period they already paid for runs out, then the subscription is supposed to finish cancelling on its own.
That last step, the actual finish, is not automatic in the sense of "WordPress notices the date passed." It depends on a scheduled Action Scheduler action, tied to that end date, whose whole job is to call the code that sets the status to cancelled. If that one scheduled action never fires, nothing else steps in to close it out. The end date comes and goes. The subscription stays on pending-cancel, sometimes for months, looking like it is still in some kind of limbo even though the customer stopped paying long ago.
Why it happens
pending-cancel depends entirely on a single scheduled action reaching the end date and firing correctly. A few common reasons it does not:
- WP-Cron never runs because the site has little real traffic, or the host disabled it and no system cron was set up to call
wp-cron.phpin its place, so the scheduled action just waits. - The Action Scheduler queue backs up with a large number of pending or failed actions, and the one that should end this subscription sits behind the backlog long past its due time.
- A site migration or a staging-to-live copy brought the subscription's pending-cancel status and end date along, but the scheduled Action Scheduler action itself was never recreated on the new environment.
- A plugin conflict or a fatal error during the hook's execution stops
woocommerce_scheduled_subscription_end_of_prepaid_termpartway through, so the action is marked failed and never retried.
WooCommerce Subscriptions documentation on subscription statuses describes pending-cancel as a holding state that ends automatically when the current period is over, driven by that one scheduled action. When Action Scheduler itself is unhealthy, as its own admin screen under WooCommerce, Status, Scheduled Actions will show, this is one of the quiet symptoms. See the citations at the end for the exact references.
A subscription's own end date is the source of truth for whether its pending-cancel period is actually over, not how long it has visually sat in that status. A reconciler is a safety net that runs on a schedule, checks that end date against today, double-checks with Stripe that nothing is quietly still billing, and finishes the cancellation the same way the scheduled action would have.
The fix, as a flow
We do not touch any subscription that is not already pending-cancel. We add a job that runs every hour or so, lists subscriptions with status pending-cancel, and for each one checks whether its end date has passed. If it has, and Stripe does not show the linked subscription as still active, trialing, or past due, we move it to cancelled through the WooCommerce REST API and leave a note explaining why.
Build it step by step
Get access to both systems
You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions, plus a Stripe secret key so the job can confirm a linked Stripe subscription is really finished before it cancels the WooCommerce side. 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="2"
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="2"
export DRY_RUN="true" // start safe, change to false to write
List every subscription stuck in pending-cancel
Ask the WooCommerce REST API for subscriptions with status pending-cancel. This is the WooCommerce Subscriptions REST API extension of the core orders endpoint, so it pages the same way. Every subscription that comes back is a candidate, not yet a subscription we are sure we should cancel.
import requests
from requests.auth import HTTPBasicAuth
WOO_URL = "https://yourstore.com"
AUTH = HTTPBasicAuth("ck_...", "cs_...")
def pending_cancel_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "pending-cancel", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for subscription in batch:
yield subscription
page += 1
const WOO_URL = "https://yourstore.com";
const AUTH = "Basic " + Buffer.from("ck_...:cs_...").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* pendingCancelSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=pending-cancel&per_page=50&page=${page}`);
if (!batch.length) return;
for (const subscription of batch) yield subscription;
page++;
}
}
Read the end date and the linked Stripe subscription
The subscription resource carries an end_date_gmt field, which WooCommerce returns as "0000-00-00 00:00:00" or empty when no end date is set. Also read the Stripe subscription id from meta, usually stored as _stripe_subscription_id. If it exists, fetch that subscription from Stripe so the decision step below can double-check it is not still billing.
from datetime import datetime, timezone
import stripe
def parse_gmt(value):
if not value or value.startswith("0000-00-00"):
return None
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
def stripe_sub_id_of(subscription):
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_subscription_id" and meta.get("value"):
return meta["value"]
return None
def get_stripe_subscription(stripe_sub_id):
if not stripe_sub_id:
return None
try:
return dict(stripe.Subscription.retrieve(stripe_sub_id))
except stripe.error.InvalidRequestError:
return None
function parseGmt(value) {
if (!value || value.startsWith("0000-00-00")) return null;
return new Date(`${value.replace(" ", "T")}Z`);
}
function stripeSubIdOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_subscription_id" && meta.value) return meta.value;
}
return null;
}
async function getStripeSubscription(stripeSubId) {
if (!stripeSubId) return null;
try {
return await stripe.subscriptions.retrieve(stripeSubId);
} catch {
return null;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription, the matching Stripe subscription (or null), and the current time, 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. Only subscriptions already on pending-cancel are considered. If the end date has not arrived, wait. If it has arrived but Stripe still shows the subscription active, trialing, or past due, hold and flag it for a human. Otherwise, cancel it.
STRIPE_LIVE_STATUSES = {"active", "trialing", "past_due"}
def decide(subscription, stripe_subscription, now):
if subscription.get("status") != "pending-cancel":
return ("skip", "subscription is not pending-cancel")
end = parse_gmt(subscription.get("end_date_gmt"))
if end is None:
return ("hold", "no end date set, cannot confirm the prepaid term is over")
if now < end:
return ("wait", "end date has not arrived yet")
if stripe_sub_id_of(subscription) and stripe_subscription is not None:
stripe_status = stripe_subscription.get("status")
if stripe_status in STRIPE_LIVE_STATUSES:
return ("hold", f"Stripe still shows the subscription as {stripe_status}")
return ("cancel", "end date has passed and Stripe does not show it still billing")
const STRIPE_LIVE_STATUSES = new Set(["active", "trialing", "past_due"]);
export function decide(subscription, stripeSubscription, now) {
if (subscription.status !== "pending-cancel") {
return ["skip", "subscription is not pending-cancel"];
}
const end = parseGmt(subscription.end_date_gmt);
if (end === null) {
return ["hold", "no end date set, cannot confirm the prepaid term is over"];
}
if (now.getTime() < end.getTime()) {
return ["wait", "end date has not arrived yet"];
}
if (stripeSubIdOf(subscription) && stripeSubscription) {
const stripeStatus = stripeSubscription.status;
if (STRIPE_LIVE_STATUSES.has(stripeStatus)) {
return ["hold", `Stripe still shows the subscription as ${stripeStatus}`];
}
}
return ["cancel", "end date has passed and Stripe does not show it still billing"];
}
Finish the cancellation the way the scheduled action would have
When the action is cancel, PUT the new status to the subscription through the REST API and add a note explaining why it moved. WooCommerce Subscriptions treats a REST API status update the same as any other status transition, so the usual cancellation hooks still run, cutting off access and stopping any remaining scheduled renewal actions for that subscription.
def cancel(subscription_id, reason):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"status": "cancelled"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Moved from pending-cancel to cancelled by the reconciler. {reason}."},
auth=AUTH, timeout=30,
).raise_for_status()
async function cancel(subscriptionId, reason) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "cancelled" }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Moved from pending-cancel to cancelled by the reconciler. ${reason}.`,
}),
});
}
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 cancel. Read the output, confirm the list is exactly what you expect, then switch it off to let it write. Run it on a schedule with cron every hour.
Always start with DRY_RUN=true. A reconciler writes to real subscription statuses, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.
The full code
Here is the complete reconciler 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 is not already on pending-cancel.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Move WooCommerce Subscriptions out of pending-cancel when they are stuck there.
pending-cancel is meant to be a short holding status: the customer cancelled, but
WooCommerce Subscriptions lets the current paid period finish before the subscription
becomes cancelled. That flip is supposed to happen through an Action Scheduler hook
named woocommerce_scheduled_subscription_end_of_prepaid_term, scheduled for the
subscription's end date. When that scheduled action never runs (Action Scheduler
stalled, WP-Cron disabled, a migration that lost the scheduled action), the
subscription sits in pending-cancel forever.
This walks subscriptions with status pending-cancel, and for any whose end date has
passed, confirms with Stripe that the subscription is not still actively billing,
then moves it to cancelled through the WooCommerce REST API. Safe to run again and
again. Dry run by default.
"""
import os
import logging
from datetime import datetime, 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("cancel_stuck_pending_cancel")
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
GRACE_HOURS = int(os.environ.get("GRACE_HOURS", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
STRIPE_LIVE_STATUSES = {"active", "trialing", "past_due"}
def stripe_sub_id_of(subscription):
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_subscription_id" and meta.get("value"):
return meta["value"]
return None
def parse_gmt(value):
if not value or value.startswith("0000-00-00"):
return None
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
def decide(subscription, stripe_subscription, now):
if subscription.get("status") != "pending-cancel":
return ("skip", "subscription is not pending-cancel")
end = parse_gmt(subscription.get("end_date_gmt"))
if end is None:
return ("hold", "no end date set, cannot confirm the prepaid term is over")
if now < end:
return ("wait", "end date has not arrived yet")
if stripe_sub_id_of(subscription) and stripe_subscription is not None:
stripe_status = stripe_subscription.get("status")
if stripe_status in STRIPE_LIVE_STATUSES:
return ("hold", f"Stripe still shows the subscription as {stripe_status}")
return ("cancel", "end date has passed and Stripe does not show it still billing")
def get_stripe_subscription(stripe_sub_id):
if not stripe_sub_id:
return None
try:
obj = stripe.Subscription.retrieve(stripe_sub_id)
return dict(obj)
except stripe.error.InvalidRequestError:
return None
def pending_cancel_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "pending-cancel", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for subscription in batch:
yield subscription
page += 1
def cancel(subscription_id, reason):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"status": "cancelled"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Moved from pending-cancel to cancelled by the reconciler. {reason}."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
now = datetime.now(timezone.utc)
cancelled = 0
for subscription in pending_cancel_subscriptions():
stripe_sub_id = stripe_sub_id_of(subscription)
stripe_subscription = get_stripe_subscription(stripe_sub_id)
action, reason = decide(subscription, stripe_subscription, now)
if action in ("skip", "wait"):
continue
if action == "hold":
log.warning("Subscription %s left in pending-cancel: %s", subscription["id"], reason)
continue
log.info(
"Subscription %s: %s. %s",
subscription["id"], reason, "would cancel" if DRY_RUN else "cancelling",
)
if not DRY_RUN:
cancel(subscription["id"], reason)
cancelled += 1
log.info("Done. %d subscription(s) %s.", cancelled, "to cancel" if DRY_RUN else "cancelled")
if __name__ == "__main__":
run()
/**
* Move WooCommerce Subscriptions out of pending-cancel when they are stuck there.
*
* pending-cancel is meant to be a short holding status: the customer cancelled, but
* WooCommerce Subscriptions lets the current paid period finish before the
* subscription becomes cancelled. That flip is supposed to happen through an Action
* Scheduler hook named woocommerce_scheduled_subscription_end_of_prepaid_term,
* scheduled for the subscription's end date. When that scheduled action never runs
* (Action Scheduler stalled, WP-Cron disabled, a migration that lost the scheduled
* action), the subscription sits in pending-cancel forever.
*
* This walks subscriptions with status pending-cancel, and for any whose end date
* has passed, confirms with Stripe that the subscription is not still actively
* billing, then moves it to cancelled through the WooCommerce REST API. Safe to run
* again and again. Dry run by default.
*/
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 || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const STRIPE_LIVE_STATUSES = new Set(["active", "trialing", "past_due"]);
export function stripeSubIdOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_subscription_id" && meta.value) return meta.value;
}
return null;
}
export function parseGmt(value) {
if (!value || value.startsWith("0000-00-00")) return null;
return new Date(`${value.replace(" ", "T")}Z`);
}
export function decide(subscription, stripeSubscription, now) {
if (subscription.status !== "pending-cancel") {
return ["skip", "subscription is not pending-cancel"];
}
const end = parseGmt(subscription.end_date_gmt);
if (end === null) {
return ["hold", "no end date set, cannot confirm the prepaid term is over"];
}
if (now.getTime() < end.getTime()) {
return ["wait", "end date has not arrived yet"];
}
if (stripeSubIdOf(subscription) && stripeSubscription) {
const stripeStatus = stripeSubscription.status;
if (STRIPE_LIVE_STATUSES.has(stripeStatus)) {
return ["hold", `Stripe still shows the subscription as ${stripeStatus}`];
}
}
return ["cancel", "end date has passed and Stripe does not show it still billing"];
}
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 getStripeSubscription(stripeSubId) {
if (!stripeSubId) return null;
try {
return await stripe.subscriptions.retrieve(stripeSubId);
} catch {
return null;
}
}
async function* pendingCancelSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=pending-cancel&per_page=50&page=${page}`);
if (!batch.length) return;
for (const subscription of batch) yield subscription;
page++;
}
}
async function cancel(subscriptionId, reason) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "cancelled" }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Moved from pending-cancel to cancelled by the reconciler. ${reason}.`,
}),
});
}
export async function run() {
const now = new Date();
let cancelled = 0;
for await (const subscription of pendingCancelSubscriptions()) {
const stripeSubId = stripeSubIdOf(subscription);
const stripeSubscription = await getStripeSubscription(stripeSubId);
const [action, reason] = decide(subscription, stripeSubscription, now);
if (action === "skip" || action === "wait") continue;
if (action === "hold") {
console.warn(`Subscription ${subscription.id} left in pending-cancel: ${reason}`);
continue;
}
console.log(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would cancel" : "cancelling"}`);
if (!DRY_RUN) await cancel(subscription.id, reason);
cancelled++;
}
console.log(`Done. ${cancelled} subscription(s) ${DRY_RUN ? "to cancel" : "cancelled"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a real customer's subscription gets cancelled. Because we kept decide pure, the test needs no network and no Stripe account or store. It just feeds in plain objects and a fixed point in time, then checks the action.
from datetime import datetime, timezone
from cancel_stuck_pending_cancel import decide
NOW = datetime(2026, 7, 10, 12, 0, 0, tzinfo=timezone.utc)
def sub(**over):
base = {
"status": "pending-cancel",
"end_date_gmt": "2026-07-01 00:00:00",
"meta_data": [],
}
base.update(over)
return base
def test_wait_when_end_date_in_future():
s = sub(end_date_gmt="2026-08-01 00:00:00")
assert decide(s, None, NOW)[0] == "wait"
def test_cancel_when_end_passed_and_no_stripe_id():
assert decide(sub(), None, NOW)[0] == "cancel"
def test_hold_when_stripe_still_active():
s = sub(meta_data=[{"key": "_stripe_subscription_id", "value": "sub_123"}])
assert decide(s, {"status": "active"}, NOW)[0] == "hold"
def test_hold_when_no_end_date_set():
s = sub(end_date_gmt="0000-00-00 00:00:00")
assert decide(s, None, NOW)[0] == "hold"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./cancel-stuck-pending-cancel.js";
const NOW = new Date("2026-07-10T12:00:00Z");
const sub = (over = {}) => ({
status: "pending-cancel",
end_date_gmt: "2026-07-01 00:00:00",
meta_data: [],
...over,
});
test("wait when end date in future", () => {
assert.equal(decide(sub({ end_date_gmt: "2026-08-01 00:00:00" }), null, NOW)[0], "wait");
});
test("cancel when end passed and no stripe id", () => {
assert.equal(decide(sub(), null, NOW)[0], "cancel");
});
test("hold when stripe still active", () => {
const s = sub({ meta_data: [{ key: "_stripe_subscription_id", value: "sub_123" }] });
assert.equal(decide(s, { status: "active" }, NOW)[0], "hold");
});
test("hold when no end date set", () => {
assert.equal(decide(sub({ end_date_gmt: "0000-00-00 00:00:00" }), null, NOW)[0], "hold");
});
Case studies
The store where Action Scheduler quietly backed up
A shop had thousands of failed actions sitting in its Action Scheduler queue after a plugin conflict, and new actions kept getting pushed further and further behind. A batch of pending-cancel subscriptions never made it to their end-of-term action, so around sixty accounts stayed pending-cancel for over two months after their customers had already stopped paying.
The reconciler on an hourly schedule found all sixty on its first run, confirmed each one's end date had long passed, and moved them to cancelled with a note. Reporting and churn numbers were accurate again the same day.
The migration that lost the scheduled actions
A store moved hosts and restored the database from a backup. The subscriptions kept their pending-cancel status and end dates, but the underlying Action Scheduler rows that would have finished them were not part of the restore, since they lived in a separate table that was skipped.
Running the reconciler in dry run showed the exact list of orphaned pending-cancel subscriptions. The team reviewed it, agreed the end dates were legitimate, and let the script finish the cancellations for real.
After this runs on a schedule, a subscription can no longer sit in pending-cancel indefinitely. The worst case becomes a delay of an hour or so before the reconciler finishes what the scheduled action should have. Keep it running even after you fix Action Scheduler, since a queue backup or a bad migration can always happen again.
FAQ
Why is my WooCommerce subscription stuck on pending-cancel?
pending-cancel is meant to hold until the current paid period ends, then a scheduled Action Scheduler action flips it to cancelled. If that scheduled action never runs, because Action Scheduler stalled, WP-Cron was disabled, or a migration lost the schedule, the subscription is left in pending-cancel with no end date ever applied.
Is it safe to cancel a subscription with a script?
Yes, when the script only acts on subscriptions whose end date has already passed and, if a Stripe subscription id is attached, Stripe agrees it is not still active, trialing, or past due. Start in dry run mode to review the list before it writes.
Will this cancel a subscription that should still be billing?
No. The reconciler only moves a subscription to cancelled if it is already in pending-cancel and its own end date is in the past. It never changes an active subscription, and it holds off if Stripe still shows the subscription actively billing.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions documentation: subscription statuses, including how pending-cancel holds until the end of the prepaid term. woocommerce.com/document/subscriptions/store-manager-guide/subscription-statuses
- WooCommerce docs: Action Scheduler, the scheduled actions admin screen, and what a stalled or backed up queue looks like. actionscheduler.org/faq
- WooCommerce Subscriptions developer docs: the hooks that run subscription status transitions, including the end of prepaid term action. woocommerce.com/document/subscriptions/develop/functions
On the solution:
- WooCommerce Subscriptions REST API: listing and updating subscriptions by status. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a Subscription and read its current status. docs.stripe.com/api/subscriptions/retrieve
- Stripe docs: subscription statuses and what active, trialing, and past_due mean. docs.stripe.com/billing/subscriptions/overview
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your stuck subscriptions?
If this cleared out a backlog of pending-cancel accounts or fixed your churn numbers, 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