Reconciler Subscription lifecycle
A WooCommerce subscription stays On-Hold after a paid renewal
The renewal charged fine. Stripe took the money, the renewal order shows Completed, and the order note even says the subscription was set back to active. But the subscription itself is still On-Hold, so a paying customer looks suspended and may lose access to what they paid for. This guide finds those subscriptions and reactivates the ones that were genuinely paid.
Under some High Performance Order Storage setups, a paid renewal logs an active note but never saves the status change, so the subscription stays On-Hold. List On-Hold subscriptions, confirm each one was really paid (the latest renewal order is paid, or the Stripe subscription is active with a paid invoice), and set those back to active through the Subscriptions REST API. The full code is below.
The problem in plain words
A WooCommerce subscription moves to On-Hold when a renewal is due or a payment has not cleared yet. Once the renewal is paid, it should go straight back to active. The bug is that the payment succeeds and the renewal order completes, but the subscription record never receives the update that flips it back to active. The order history says active, the money is in your account, and yet the subscription sits On-Hold.
For the customer this looks like a suspension. They paid, but their access is cut off, and they open a support ticket asking why. Multiply that across a renewal day and it becomes a wave of confused, paying customers.
Why it happens
This has been reported on stores using High Performance Order Storage with Stripe-driven billing. The renewal path logs the status transition to active, but the write to the subscription record does not persist, so the stored status stays On-Hold. The order note and the real status disagree. The citations at the end link the report.
The proof of payment lives in two reliable places. The latest renewal order for the subscription is marked paid, and, for Stripe-driven billing, the Stripe subscription is active with a paid latest invoice. If either of those says paid while the WooCommerce subscription says On-Hold, the status simply failed to save and is safe to correct.
The fix, as a flow
We list On-Hold subscriptions through the Subscriptions REST API. For each one we confirm it was really paid, then set it back to active. The confirmation is what keeps this safe. We never reactivate a subscription that has not proven it was paid, so a genuinely unpaid, correctly held subscription is left alone.
Build it step by step
Decide with a pure function
Keep the rule in one place. Take the subscription status and two proofs of payment: whether the latest renewal order is paid, and whether the Stripe subscription is active with a paid invoice. Reactivate only when the subscription is On-Hold and at least one proof says paid.
def should_reactivate(sub_status, latest_renewal_paid, stripe_active_and_paid):
if sub_status != "on-hold":
return False
return bool(latest_renewal_paid or stripe_active_and_paid)
export function shouldReactivate(subStatus, latestRenewalPaid, stripeActiveAndPaid) {
if (subStatus !== "on-hold") return false;
return Boolean(latestRenewalPaid || stripeActiveAndPaid);
}
List On-Hold subscriptions and their proof
The Subscriptions REST API lists subscriptions by status. Each subscription tells you its latest related order and, in its meta, the Stripe subscription ID when Stripe drives the billing. We read the renewal order to see if it is paid, and optionally check Stripe for a second confirmation.
PAID_STATUSES = {"processing", "completed"}
def on_hold_subscriptions():
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "on-hold", "per_page": 50, "page": page},
auth=AUTH, timeout=30)
r.raise_for_status()
subs = r.json()
if not subs:
return
for sub in subs:
yield sub
page += 1
def latest_renewal_paid(sub):
order = get_order(sub.get("last_order_id") or sub["parent_id"])
return bool(order) and order["status"] in PAID_STATUSES
const PAID_STATUSES = new Set(["processing", "completed"]);
export async function* onHoldSubscriptions() {
let page = 1;
while (true) {
const subs = await woo(`/subscriptions?status=on-hold&per_page=50&page=${page}`);
if (!subs.length) return;
for (const sub of subs) yield sub;
page++;
}
}
export async function latestRenewalPaid(sub) {
const order = await woo(`/orders/${sub.last_order_id || sub.parent_id}`);
return Boolean(order) && PAID_STATUSES.has(order.status);
}
Set the subscription back to active
When the proof holds, update the subscription status to active through the Subscriptions REST API and add a note. This corrects only the status that failed to save. It does not move the next payment date or create any charge, so nothing about the billing schedule changes.
def reactivate(sub_id):
requests.put(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"status": "active"}, auth=AUTH, timeout=30).raise_for_status()
requests.post(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": "Renewal was paid but the subscription stayed on-hold. "
"Set back to active by the reconciler."},
auth=AUTH, timeout=30).raise_for_status()
async function reactivate(subId) {
await woo(`/subscriptions/${subId}`, { method: "PUT", body: JSON.stringify({ status: "active" }) });
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Renewal was paid but the subscription stayed on-hold. Set back to active by the reconciler.",
}),
});
}
Never set a subscription active just because it is On-Hold. Always confirm the renewal was paid first, through the paid order or the Stripe subscription. Start with DRY_RUN=true and read which subscriptions it would reactivate before letting it write.
The full code
The complete reconciler lists On-Hold subscriptions, confirms each was paid through the renewal order or the Stripe subscription, and reactivates only those. It is safe to run again because an already active subscription is skipped.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Reactivate WooCommerce subscriptions that stayed On-Hold after a paid renewal.
Confirms payment first, so it only fixes subscriptions that were genuinely paid.
Run on a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/woocommerce/subscription-on-hold-after-successful-renewal/
"""
import os
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("reactivate_paid_subs")
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAID_STATUSES = {"processing", "completed"}
def should_reactivate(sub_status, latest_renewal_paid, stripe_active_and_paid):
if sub_status != "on-hold":
return False
return bool(latest_renewal_paid or stripe_active_and_paid)
def on_hold_subscriptions():
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "on-hold", "per_page": 50, "page": page},
auth=AUTH, timeout=30)
r.raise_for_status()
subs = r.json()
if not subs:
return
for sub in subs:
yield sub
page += 1
def get_order(order_id):
if not order_id:
return None
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def latest_renewal_paid(sub):
order = get_order(sub.get("last_order_id") or sub.get("parent_id"))
return bool(order) and order["status"] in PAID_STATUSES
def get_meta(sub, key):
for m in sub.get("meta_data", []):
if m.get("key") == key:
return m.get("value")
return None
def stripe_active_and_paid(sub):
sub_id = get_meta(sub, "_wcpay_subscription_id") or get_meta(sub, "_stripe_subscription_id")
if not sub_id or not stripe.api_key:
return False
s = stripe.Subscription.retrieve(sub_id, expand=["latest_invoice"])
invoice = s.get("latest_invoice") or {}
return s.get("status") in ("active", "trialing") and invoice.get("status") == "paid"
def reactivate(sub_id):
requests.put(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"status": "active"}, auth=AUTH, timeout=30).raise_for_status()
requests.post(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": "Renewal was paid but the subscription stayed on-hold. "
"Set back to active by the reconciler."},
auth=AUTH, timeout=30).raise_for_status()
def run():
fixed = 0
for sub in on_hold_subscriptions():
paid_order = latest_renewal_paid(sub)
paid_stripe = False if paid_order else stripe_active_and_paid(sub)
if not should_reactivate(sub["status"], paid_order, paid_stripe):
continue
log.info("Subscription %s: paid but on-hold. %s", sub["id"], "would reactivate" if DRY_RUN else "reactivating")
if not DRY_RUN:
reactivate(sub["id"])
fixed += 1
log.info("Done. %d subscription(s) %s.", fixed, "to reactivate" if DRY_RUN else "reactivated")
if __name__ == "__main__":
run()
/**
* Reactivate WooCommerce subscriptions that stayed On-Hold after a paid renewal.
* Confirms payment first, so it only fixes subscriptions that were genuinely paid.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/woocommerce/subscription-on-hold-after-successful-renewal/
*/
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAID_STATUSES = new Set(["processing", "completed"]);
export function shouldReactivate(subStatus, latestRenewalPaid, stripeActiveAndPaid) {
if (subStatus !== "on-hold") return false;
return Boolean(latestRenewalPaid || stripeActiveAndPaid);
}
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* onHoldSubscriptions() {
let page = 1;
while (true) {
const subs = await woo(`/subscriptions?status=on-hold&per_page=50&page=${page}`);
if (!subs.length) return;
for (const sub of subs) yield sub;
page++;
}
}
async function latestRenewalPaid(sub) {
const order = await woo(`/orders/${sub.last_order_id || sub.parent_id}`);
return Boolean(order) && PAID_STATUSES.has(order.status);
}
function getMeta(sub, key) {
const hit = (sub.meta_data || []).find((m) => m.key === key);
return hit ? hit.value : null;
}
async function stripeActiveAndPaid(sub) {
const subId = getMeta(sub, "_wcpay_subscription_id") || getMeta(sub, "_stripe_subscription_id");
if (!subId || !process.env.STRIPE_SECRET_KEY) return false;
const s = await stripe.subscriptions.retrieve(subId, { expand: ["latest_invoice"] });
const invoice = s.latest_invoice || {};
return ["active", "trialing"].includes(s.status) && invoice.status === "paid";
}
async function reactivate(subId) {
await woo(`/subscriptions/${subId}`, { method: "PUT", body: JSON.stringify({ status: "active" }) });
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({ note: "Renewal was paid but the subscription stayed on-hold. Set back to active by the reconciler." }),
});
}
async function run() {
let fixed = 0;
for await (const sub of onHoldSubscriptions()) {
const paidOrder = await latestRenewalPaid(sub);
const paidStripe = paidOrder ? false : await stripeActiveAndPaid(sub);
if (!shouldReactivate(sub.status, paidOrder, paidStripe)) continue;
console.log(`Subscription ${sub.id}: paid but on-hold. ${DRY_RUN ? "would reactivate" : "reactivating"}`);
if (!DRY_RUN) await reactivate(sub.id);
fixed++;
}
console.log(`Done. ${fixed} subscription(s) ${DRY_RUN ? "to reactivate" : "reactivated"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The reactivation rule is the safety-critical piece, since it decides which subscriptions get flipped to active. It is pure, so the test just passes in the status and the two proofs.
from reactivate_paid_subs import should_reactivate
def test_reactivate_when_order_paid():
assert should_reactivate("on-hold", True, False) is True
def test_reactivate_when_stripe_paid():
assert should_reactivate("on-hold", False, True) is True
def test_leave_when_not_paid():
assert should_reactivate("on-hold", False, False) is False
def test_ignore_non_on_hold():
assert should_reactivate("active", True, True) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { shouldReactivate } from "./reactivate-paid-subs.js";
test("reactivate when order paid", () => {
assert.equal(shouldReactivate("on-hold", true, false), true);
});
test("reactivate when stripe paid", () => {
assert.equal(shouldReactivate("on-hold", false, true), true);
});
test("leave when not paid", () => {
assert.equal(shouldReactivate("on-hold", false, false), false);
});
test("ignore non on-hold", () => {
assert.equal(shouldReactivate("active", true, true), false);
});
Case studies
The morning of suspended members
A membership site ran renewals overnight. Payments went through, but a batch of subscriptions stayed On-Hold, so members woke up locked out of content they had just paid for. Support was flooded before coffee.
The reconciler checked each held subscription against its paid renewal order, reactivated the ones that were paid, and restored access. The rest of the morning was quiet.
The subscription Stripe knew was active
On a store where Stripe drove the billing, the Stripe subscription showed active with a paid invoice, but WooCommerce still had the subscription On-Hold after the HPOS status write was lost.
The script matched the two by the stored Stripe subscription ID, confirmed active and paid on the Stripe side, and set WooCommerce back to active to match reality.
After this runs on a schedule, a paid renewal never leaves a customer suspended. The subscription status matches the payment within minutes, access stays on, and your support inbox is not full of people who already paid.
FAQ
Why is my WooCommerce subscription on hold when the renewal was paid?
Under some High Performance Order Storage setups, a paid renewal writes an order note saying the subscription is active, but the status change is never saved to the subscription record. So the renewal order is complete and the money is taken, yet the subscription stays On-Hold and the customer looks suspended.
Is it safe to set a subscription back to active with a script?
Yes, when the script first confirms the payment really succeeded, either that the latest renewal order is paid or that the Stripe subscription is active with a paid invoice. It only reactivates subscriptions that are provably paid, and it skips everything else.
Will reactivating change the next payment date?
No. Setting the status back to active does not move the schedule. It only corrects the status that failed to save, so the next payment date stays exactly where the renewal left it.
Related field notes
Citations
On the problem:
- WooCommerce Payments issue: subscription stuck on-hold after a successful renewal under HPOS. github.com/Automattic/woocommerce-payments/issues/5264
- WooCommerce docs: subscription statuses and what On-Hold means. woocommerce.com/document/subscriptions/statuses
- WooCommerce docs: High Performance Order Storage and data sync. developer.woocommerce.com HPOS
On the solution:
- WooCommerce Subscriptions REST API: list and update subscriptions. woocommerce.github.io/subscriptions-rest-api-docs
- Stripe API: retrieve a subscription and expand the latest invoice. docs.stripe.com/api/subscriptions/retrieve
- WooCommerce REST API: read an order to confirm it is paid. 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 un-suspend your paying customers?
If this reactivated subscriptions that were paid but stuck, you can buy me a coffee. It keeps these field notes free and growing.
Buy me a coffee on Ko-fi