Reconciler WooCommerce Subscriptions: schedules and dates
Subscription will not end
A customer signed up for a plan that was supposed to run for a fixed number of payments, and then stop. Six months after the end date passed, it is still active, still charging the card, and nobody set it to keep going. This is what happens when the one scheduled task that should have stopped it never fires. Here is why it happens and a small script that finds every overdue subscription and expires it in a safe way.
A subscription with a schedule end_date is meant to stop itself through an Action Scheduler hook that fires on that date. If the hook was never queued, got deleted, or missed its run while the site was down, nothing moves the subscription to Expired, so it keeps renewing forever. Run a small Python or Node.js check on a schedule that reads open subscriptions from the WooCommerce REST API, compares each end_date_gmt to now, and moves any subscription overdue by more than a short grace window to Expired. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce Subscriptions lets a plan have a fixed length, like "bill monthly for twelve months, then stop." Behind the scenes this is not magic. WooCommerce Subscriptions schedules a task with Action Scheduler for the exact moment the subscription should end, and when that task runs, it moves the subscription to Expired and cancels any future renewal.
That scheduled task is just another row in a database table, waiting for WordPress cron to wake it up. If the site was down at that moment, if a cleanup tool deleted the row, or if the action was never scheduled correctly in the first place, the subscription has no way to know its own end date passed. It keeps renewing on schedule, quietly, until someone happens to notice the date on the invoice looks wrong.
Why it happens
WooCommerce Subscriptions relies entirely on Action Scheduler to move a subscription through its lifecycle, and the official docs are clear that scheduled actions depend on WordPress cron running on time. A few common reasons the end date hook never fires:
- WordPress cron is disabled or throttled by low traffic, so scheduled actions queue up and some run very late or effectively never, since a real cron replacement was never wired in.
- A cleanup plugin or a manual database cleanup deleted "old" or "completed" Action Scheduler rows, and swept up a still-pending end date action along with them.
- The subscription's
end_datewas changed after the original scheduled action was set (a plan change, a manual edit), and the old date's hook was cancelled without a new one being queued for the new date. - The store was migrated or restored from a backup taken after the scheduled action should have run but before it did, so the action row simply does not exist on the new environment.
This is a known category of issue. WooCommerce Subscriptions support threads describe subscriptions that pass their end date and keep renewing because the scheduled expiration action was missing or stuck, with the fix usually being a manual status change per subscription. See the citations at the end for the exact references.
The subscription record itself, not the scheduler, holds the truth about when it should end. If end_date_gmt is set and is in the past, and the subscription is still Active, On hold, or Pending cancellation, that subscription is overdue, no matter what the scheduler did or did not do. A small check that reads the end date directly and acts on it is a safety net underneath Action Scheduler, not a replacement for it.
The fix, as a flow
We do not touch the plan logic or Action Scheduler itself. We add a check that runs once an hour, reads every subscription that is still open, and compares its end date to the current time. If the end date has passed by more than a short grace window, we move the subscription to Expired through the REST API and leave a note, the same outcome the missed hook should have produced. We also take the chance to cancel any stale Stripe PaymentIntent left sitting on that subscription, so an old off-session mandate does not linger after the plan should be gone.
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. 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 the subscriptions that are still open
Ask the WooCommerce Subscriptions REST API for subscriptions with status active, on-hold, or pending-cancel. Anything already expired or cancelled is done and does not need a second look, so we never even fetch those.
import os, 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"])
def open_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,on-hold,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 = 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) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
headers: { "Content-Type": "application/json", Authorization: AUTH },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* openSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,on-hold,pending-cancel&per_page=50&page=${page}`);
if (!batch.length) return;
for (const subscription of batch) yield subscription;
page++;
}
}
Read the end date safely
WooCommerce stores an unset date as the string 0000-00-00 00:00:00, not null, so that has to be treated the same as "no end date." Everything else parses as a plain UTC timestamp, since the REST API always reports *_date_gmt fields in GMT.
from datetime import datetime, timezone
NO_END_DATE = "0000-00-00 00:00:00"
def parse_gmt(value):
"""Parse a WooCommerce *_date_gmt string into an aware UTC datetime, or None."""
if not value or value == NO_END_DATE:
return None
text = value.replace("T", " ").replace("Z", "")
return datetime.strptime(text, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
const NO_END_DATE = "0000-00-00 00:00:00";
function parseGmt(value) {
if (!value || value === NO_END_DATE) return null;
const iso = value.replace("T", " ").replace("Z", "").replace(" ", "T") + "Z";
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : date;
}
Decide, with one pure function
Keep the decision in its own function that takes a subscription 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. If the subscription is not open, skip it. If it has no end date, skip it, since it renews until cancelled by choice. If the end date has not arrived, skip it. If it just passed, wait out a short grace window in case the scheduled hook is only running a little late. Otherwise, expire it.
OPEN_STATUSES = {"active", "on-hold", "pending-cancel"}
GRACE_HOURS = 6
def decide(subscription, now):
if subscription["status"] not in OPEN_STATUSES:
return ("skip", "subscription is not in an open state")
end_date = parse_gmt(subscription.get("end_date_gmt"))
if end_date is None:
return ("skip", "subscription has no end date, it renews until cancelled")
if now < end_date:
return ("skip", "end date has not arrived yet")
overdue_hours = (now - end_date).total_seconds() / 3600
if overdue_hours < GRACE_HOURS:
return ("wait", "end date passed but still inside the grace window")
return ("expire", f"end date passed {overdue_hours:.1f}h ago and is still open")
const OPEN_STATUSES = new Set(["active", "on-hold", "pending-cancel"]);
const GRACE_HOURS = 6;
export function decide(subscription, now) {
if (!OPEN_STATUSES.has(subscription.status)) {
return ["skip", "subscription is not in an open state"];
}
const endDate = parseGmt(subscription.end_date_gmt);
if (!endDate) {
return ["skip", "subscription has no end date, it renews until cancelled"];
}
if (now < endDate) {
return ["skip", "end date has not arrived yet"];
}
const overdueHours = (now - endDate) / 3600000;
if (overdueHours < GRACE_HOURS) {
return ["wait", "end date passed but still inside the grace window"];
}
return ["expire", `end date passed ${overdueHours.toFixed(1)}h ago and is still open`];
}
Expire it the way the scheduled hook would
When the action is expire, set the subscription status to Expired and add a note explaining why, so the shop manager can see it was repaired and not just changed at random. We also take the chance to look up the saved PaymentIntent, using the same _stripe_intent_id meta key or transaction_id that a Stripe fix script reads, and cancel it if it is still sitting in a state that could be captured or confirmed later. That step is a courtesy cleanup and is wrapped so it can never block the expiry itself.
def intent_id_of(subscription):
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = subscription.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def cancel_stale_mandate(subscription):
intent_id = intent_id_of(subscription)
if not intent_id:
return
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
if intent.status in ("requires_capture", "requires_confirmation", "requires_action"):
stripe.PaymentIntent.cancel(intent_id)
except stripe.error.StripeError:
pass # best effort, never blocks the expiry
def mark_expired(subscription_id, reason):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"status": "expired"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Expired by the scheduled check: {reason}. "
f"The end date had passed but the subscription was still open."},
auth=AUTH, timeout=30,
).raise_for_status()
export function intentIdOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = subscription.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
async function cancelStaleMandate(subscription) {
const intentId = intentIdOf(subscription);
if (!intentId) return;
try {
const intent = await stripe.paymentIntents.retrieve(intentId);
if (["requires_capture", "requires_confirmation", "requires_action"].includes(intent.status)) {
await stripe.paymentIntents.cancel(intentId);
}
} catch {
// best effort, never blocks the expiry
}
}
async function markExpired(subscriptionId, reason) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "expired" }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Expired by the scheduled check: ${reason}. ` +
`The end date had passed but the subscription was still open.`,
}),
});
}
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 do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once an hour.
Always start with DRY_RUN=true. Expiring a subscription stops real billing, 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 check in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches a subscription that is not overdue past its grace window.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Expire WooCommerce Subscriptions whose end date has already passed.
A subscription with a schedule "end_date" is supposed to stop billing and move to
Expired on its own, driven by an Action Scheduler hook. If that hook was deleted,
never queued, or missed its run while the site was down, the subscription just sits
on Active (or On hold) forever with an end date in the past. This walks subscriptions
with a set end_date, checks whether that date has passed, and moves any overdue one to
Expired through the REST API, the same way the scheduled hook would have. It also
confirms with Stripe that there is no unexpected still-active off-session mandate on
an already-overdue subscription, since a store deleting a stale mandate is safer than
leaving it live once the subscription should be gone. Read only by default. Run on a
schedule.
"""
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("expire_overdue_subscriptions")
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"
OPEN_STATUSES = {"active", "on-hold", "pending-cancel"}
NO_END_DATE = "0000-00-00 00:00:00"
def parse_gmt(value):
"""Parse a WooCommerce *_date_gmt string into an aware UTC datetime, or None."""
if not value or value == NO_END_DATE:
return None
text = value.replace("T", " ").replace("Z", "")
return datetime.strptime(text, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
def intent_id_of(subscription):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = subscription.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def decide(subscription, now):
"""Pure decision: should this subscription be expired right now?
subscription is a plain dict shaped like the WooCommerce Subscriptions REST API
response (status, end_date_gmt, meta_data, transaction_id). now is an aware
datetime, passed in so the function has no hidden clock and stays pure.
"""
if subscription["status"] not in OPEN_STATUSES:
return ("skip", "subscription is not in an open state")
end_date = parse_gmt(subscription.get("end_date_gmt"))
if end_date is None:
return ("skip", "subscription has no end date, it renews until cancelled")
if now < end_date:
return ("skip", "end date has not arrived yet")
overdue_hours = (now - end_date).total_seconds() / 3600
if overdue_hours < GRACE_HOURS:
return ("wait", "end date passed but still inside the grace window")
return ("expire", f"end date passed {overdue_hours:.1f}h ago and is still open")
def get_subscription(subscription_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}", auth=AUTH, timeout=30
)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def open_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,on-hold,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_stale_mandate(subscription):
"""Best-effort: cancel a Stripe PaymentIntent still sitting in a capturable state
on a subscription we are about to expire. Never raises, since this is a courtesy
cleanup and must not block the expiry itself.
"""
intent_id = intent_id_of(subscription)
if not intent_id:
return
try:
intent = stripe.PaymentIntent.retrieve(intent_id)
if intent.status in ("requires_capture", "requires_confirmation", "requires_action"):
stripe.PaymentIntent.cancel(intent_id)
log.info("Cancelled stale PaymentIntent %s on subscription %s", intent_id, subscription["id"])
except stripe.error.StripeError as exc:
log.warning("Could not check/cancel PaymentIntent %s: %s", intent_id, exc)
def mark_expired(subscription_id, reason):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"status": "expired"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Expired by the scheduled check: {reason}. "
f"The end date had passed but the subscription was still open."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
now = datetime.now(timezone.utc)
expired = 0
for subscription in open_subscriptions():
action, reason = decide(subscription, now)
if action != "expire":
continue
log.info("Subscription %s: %s. %s", subscription["id"], reason,
"would expire" if DRY_RUN else "expiring")
if not DRY_RUN:
cancel_stale_mandate(subscription)
mark_expired(subscription["id"], reason)
expired += 1
log.info("Done. %d subscription(s) %s.", expired, "to expire" if DRY_RUN else "expired")
if __name__ == "__main__":
run()
/**
* Expire WooCommerce Subscriptions whose end date has already passed.
*
* A subscription with a schedule "end_date" is supposed to stop billing and move to
* Expired on its own, driven by an Action Scheduler hook. If that hook was deleted,
* never queued, or missed its run while the site was down, the subscription just sits
* on Active (or On hold) forever with an end date in the past. This walks
* subscriptions with a set end_date, checks whether that date has passed, and moves
* any overdue one to Expired through the REST API, the same way the scheduled hook
* would have. It also does a best-effort check with Stripe to cancel a stale
* PaymentIntent left on a subscription that should already be gone. Read only by
* default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/subscription-will-not-end/
*/
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 OPEN_STATUSES = new Set(["active", "on-hold", "pending-cancel"]);
const NO_END_DATE = "0000-00-00 00:00:00";
export function parseGmt(value) {
if (!value || value === NO_END_DATE) return null;
const text = value.replace("T", " ").replace("Z", "") + "Z";
const iso = text.replace(" ", "T");
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : date;
}
export function intentIdOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = subscription.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
/**
* Pure decision: should this subscription be expired right now?
* `now` is a Date, passed in so the function has no hidden clock and stays pure.
*/
export function decide(subscription, now) {
if (!OPEN_STATUSES.has(subscription.status)) {
return ["skip", "subscription is not in an open state"];
}
const endDate = parseGmt(subscription.end_date_gmt);
if (!endDate) {
return ["skip", "subscription has no end date, it renews until cancelled"];
}
if (now < endDate) {
return ["skip", "end date has not arrived yet"];
}
const overdueHours = (now - endDate) / 3600000;
if (overdueHours < GRACE_HOURS) {
return ["wait", "end date passed but still inside the grace window"];
}
return ["expire", `end date passed ${overdueHours.toFixed(1)}h ago and is still open`];
}
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* openSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,on-hold,pending-cancel&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const subscription of batch) yield subscription;
page++;
}
}
async function cancelStaleMandate(subscription) {
const intentId = intentIdOf(subscription);
if (!intentId) return;
try {
const intent = await stripe.paymentIntents.retrieve(intentId);
if (["requires_capture", "requires_confirmation", "requires_action"].includes(intent.status)) {
await stripe.paymentIntents.cancel(intentId);
console.log(`Cancelled stale PaymentIntent ${intentId} on subscription ${subscription.id}`);
}
} catch (err) {
console.warn(`Could not check/cancel PaymentIntent ${intentId}: ${err.message}`);
}
}
async function markExpired(subscriptionId, reason) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "expired" }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Expired by the scheduled check: ${reason}. ` +
`The end date had passed but the subscription was still open.`,
}),
});
}
export async function run() {
const now = new Date();
let expired = 0;
for await (const subscription of openSubscriptions()) {
const [action, reason] = decide(subscription, now);
if (action !== "expire") continue;
console.log(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would expire" : "expiring"}`);
if (!DRY_RUN) {
await cancelStaleMandate(subscription);
await markExpired(subscription.id, reason);
}
expired++;
}
console.log(`Done. ${expired} subscription(s) ${DRY_RUN ? "to expire" : "expired"}.`);
}
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 real, still-billing subscriptions get touched. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and a fixed point in time, then checks the action.
from datetime import datetime, timezone
from expire_overdue_subscriptions import decide
NOW = datetime(2026, 7, 10, 12, 0, 0, tzinfo=timezone.utc)
def sub(**over):
base = {"status": "active", "end_date_gmt": "2026-07-01 00:00:00"}
base.update(over)
return base
def test_expire_when_end_date_passed_and_open():
assert decide(sub(), NOW)[0] == "expire"
def test_skip_when_no_end_date():
assert decide(sub(end_date_gmt="0000-00-00 00:00:00"), NOW)[0] == "skip"
def test_skip_when_end_date_in_future():
assert decide(sub(end_date_gmt="2026-08-01 00:00:00"), NOW)[0] == "skip"
def test_wait_inside_grace_window():
recent = sub(end_date_gmt="2026-07-10 08:00:00") # 4 hours ago, inside 6h grace
assert decide(recent, NOW)[0] == "wait"
def test_expire_past_grace_window():
old = sub(end_date_gmt="2026-07-10 04:00:00") # 8 hours ago, past 6h grace
assert decide(old, NOW)[0] == "expire"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./expire-overdue-subscriptions.js";
const NOW = new Date("2026-07-10T12:00:00Z");
const sub = (over = {}) => ({ status: "active", end_date_gmt: "2026-07-01 00:00:00", ...over });
test("expire when end date passed and open", () => {
assert.equal(decide(sub(), NOW)[0], "expire");
});
test("skip when no end date", () => {
assert.equal(decide(sub({ end_date_gmt: "0000-00-00 00:00:00" }), NOW)[0], "skip");
});
test("skip when end date in future", () => {
assert.equal(decide(sub({ end_date_gmt: "2026-08-01 00:00:00" }), NOW)[0], "skip");
});
test("wait inside grace window", () => {
const recent = sub({ end_date_gmt: "2026-07-10 08:00:00" }); // 4 hours ago, inside 6h grace
assert.equal(decide(recent, NOW)[0], "wait");
});
test("expire past grace window", () => {
const old = sub({ end_date_gmt: "2026-07-10 04:00:00" }); // 8 hours ago, past 6h grace
assert.equal(decide(old, NOW)[0], "expire");
});
Case studies
The store that moved hosts and lost its schedule
A shop migrated to a new host, restoring a database backup taken a few hours before the move. A dozen limited-length subscriptions had end dates that fell inside that gap, and their expiration actions simply did not exist on the new server. Every one of them kept renewing for weeks.
The hourly check found all twelve on its first real run, since their end dates were long past. Dry run listed them, the team confirmed the dates against the original order details, and the next run expired them cleanly with a note on each.
The end date that moved but the old hook did not go away
A customer upgraded from a six month plan to a twelve month plan mid-way through. The new end date was saved correctly, but the store's cleanup routine had already cancelled the original scheduled action and nothing re-queued a new one for the updated date.
Nobody noticed until the twelve month mark passed and the subscription kept billing. The check picked it up on the very next hourly run, since it reads the current end_date_gmt straight from the subscription rather than trusting whatever action might or might not still be scheduled.
After this runs on a schedule, a missed expiration hook is no longer a subscription that quietly outlives its own contract. The worst case becomes a delay of at most an hour plus the grace window before the check catches it. Keep it running even after you track down why a hook went missing, because Action Scheduler will occasionally lose a row no matter how healthy the site is.
FAQ
Why does a WooCommerce subscription with an end date keep renewing?
The end date is supposed to trigger an Action Scheduler hook that moves the subscription to Expired. If that hook was never queued, was deleted, or missed its run while the site was down, nothing tells the subscription to stop, so it keeps renewing past the date the customer agreed to.
Is it safe to expire a subscription with a script?
Yes, when the script only acts on subscriptions that are still open (Active, On hold, or Pending cancellation), have a real end date in the past, and have been overdue for longer than a short grace window. Start in dry run mode to review the list before it writes.
How often should this check run?
Once an hour is plenty. End dates do not move by the minute, so an hourly run catches an overdue subscription quickly without adding any real load.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: how the subscription lifecycle and scheduled status changes work. woocommerce.com/document/subscriptions
- Action Scheduler docs: scheduled actions depend on WordPress cron and can be missed or cleaned up. actionscheduler.org/faq
- WooCommerce Subscriptions support: subscriptions that pass their end date and keep renewing. github.com/woocommerce/woocommerce-subscriptions-core/issues
On the solution:
- WooCommerce Subscriptions REST API: read and update a subscription, including
end_date_gmtand status. woocommerce.github.io/subscriptions-rest-api-docs - Stripe API: retrieve and cancel a PaymentIntent. docs.stripe.com/api/payment_intents/cancel
- WooCommerce REST API: add a note to an order or subscription. 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 subscriptions?
If this saved you a pile of quietly overbilled customers, 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