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.

Python and Node.js Runs on a schedule Safe by default (dry run)
A planner with two pens
Photo by 2H Media on Unsplash
The short answer

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.

end_date arrives on the subscription Action Scheduler should fire the hook hook missing Still Active end date ignored Keeps billing past the date
The end date passes without event. The subscription has no other way to notice it should stop, so it renews as if nothing happened.

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:

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 key insight

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.

Scheduled job once an hour List open subscriptions Read end_date_gmt compare to now Overdue past the grace time? yes no, skip Mark Expired save note + tidy Stripe
The check reads the truth from the subscription's own end date and only acts once a subscription is overdue past a short grace window. Everything still on schedule 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 subscriptions. 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 GRACE_HOURS="6"
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 GRACE_HOURS="6"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

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

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.

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

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.

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

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.

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

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.

Run it safe

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_overdue_subscriptions.py
"""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-overdue-subscriptions.js
/**
 * 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.

test_subscription_expiry_decide.py
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"
expire-overdue-subscriptions.test.js
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

Server migration

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.

Plan change

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.

What good looks like

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:

  1. WooCommerce Subscriptions docs: how the subscription lifecycle and scheduled status changes work. woocommerce.com/document/subscriptions
  2. Action Scheduler docs: scheduled actions depend on WordPress cron and can be missed or cleaned up. actionscheduler.org/faq
  3. WooCommerce Subscriptions support: subscriptions that pass their end date and keep renewing. github.com/woocommerce/woocommerce-subscriptions-core/issues

On the solution:

  1. WooCommerce Subscriptions REST API: read and update a subscription, including end_date_gmt and status. woocommerce.github.io/subscriptions-rest-api-docs
  2. Stripe API: retrieve and cancel a PaymentIntent. docs.stripe.com/api/payment_intents/cancel
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes