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.

Python and Node.js Runs on a schedule Only reactivates paid subs
A calendar with red push buttons pinned to it
Photo by Towfiqu barbhuiya on Unsplash
The short answer

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.

Renewal paid Stripe charged Order completed note says active status not saved Still On-Hold looks suspended Access cut ticket opened
Everything about the payment worked. Only the final status write was lost, so the customer is wrongly held.

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

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.

List On-Hold subs Subscriptions API really paid? order or Stripe yes no, leave held Set active status fixed, note added
Reactivate only the subscriptions that prove they were paid. Everything else stays as it is.

Build it step by step

1

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.

decide.py
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)
decide.js
export function shouldReactivate(subStatus, latestRenewalPaid, stripeActiveAndPaid) {
  if (subStatus !== "on-hold") return false;
  return Boolean(latestRenewalPaid || stripeActiveAndPaid);
}
2

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.

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

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.

reactivate.py
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()
reactivate.js
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.",
    }),
  });
}
Confirm payment before you reactivate

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_paid_subs.py
"""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-paid-subs.js
/**
 * 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.

test_decide.py
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
decide.test.js
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

Renewal day

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.

Stripe billing

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.

What good looks like

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:

  1. WooCommerce Payments issue: subscription stuck on-hold after a successful renewal under HPOS. github.com/Automattic/woocommerce-payments/issues/5264
  2. WooCommerce docs: subscription statuses and what On-Hold means. woocommerce.com/document/subscriptions/statuses
  3. WooCommerce docs: High Performance Order Storage and data sync. developer.woocommerce.com HPOS

On the solution:

  1. WooCommerce Subscriptions REST API: list and update subscriptions. woocommerce.github.io/subscriptions-rest-api-docs
  2. Stripe API: retrieve a subscription and expand the latest invoice. docs.stripe.com/api/subscriptions/retrieve
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes