Repair WooCommerce Subscriptions: status and renewals

Cannot reactivate a pending-cancel WooCommerce Subscription

The customer changed their mind. Support clicks Reactivate. WooCommerce Subscriptions says no. The subscription stays on pending-cancel, still ticking down toward the end date it was scheduled to cancel on. This is a small, well-known trap in the subscription status machine, and it is fixed with two writes in the right order, not one forced status change.

Python and Node.js Run per subscription id Safe by default (dry run)
"can't" printed text
Photo by Stefano Ghezzi on Unsplash
The short answer

A pending-cancel subscription still has a scheduled end date on it, the date it will fully cancel once the paid term runs out. WooCommerce Subscriptions will not let that subscription jump straight back to active while the end date is still set. Clear the scheduled end date first, confirm the saved Stripe payment method still works, then set the status to active. A small Python or Node.js script does this safely in that order, with a dry run guard. Full code and tests are below.

The problem in plain words

When a customer asks to cancel a subscription, WooCommerce Subscriptions usually does not cancel it right away. It moves the subscription to pending-cancel and lets the customer keep access until the end of the period they already paid for. To do that, it writes a scheduled end date onto the subscription, the exact moment it will finish cancelling.

If the customer changes their mind before that date, the natural move is to reactivate the subscription. But the status machine inside WooCommerce Subscriptions checks what is allowed to move where, and pending-cancel to active is not one of the paths it allows while that end date is still sitting on the subscription. The reactivation is refused, quietly, and the subscription is left exactly where it was.

Sub is pending-cancel Support clicks Reactivate end date blocks it Status rejected still pending-cancel Sub still set to end
The status change is refused because the scheduled end date is still on the subscription. Nothing else moves.

Why it happens

The WooCommerce Subscriptions plugin keeps a strict map of which statuses can move to which other statuses. This is by design, so a subscription cannot end up in a state that does not make sense, like active with a renewal that already failed. A few things line up to cause this specific trap:

The official docs describe pending-cancel as an in-between status specifically meant to let access continue until the end of term. Reactivating out of it is documented as a supported action, but it only works cleanly when the end date is cleared as part of the same operation, not treated as a side detail.

The key insight

Pending-cancel is not just a status label, it is a status plus a scheduled date. Reactivating means undoing both halves, not one. Clear the scheduled end date, confirm the subscription can still be billed, and only then move the status. Do it in that order and the plugin's own status machine has nothing left to object to.

The fix, as a flow

We do not bypass WooCommerce Subscriptions or write directly to the database. We use the same REST API a support tool would use, in the order that actually satisfies the plugin's rules. First we confirm the subscription is really stuck on pending-cancel. Then we check that the customer's saved card still works with Stripe, since reactivating a subscription that is about to fail its next renewal just moves the problem forward a few days. Only then do we clear the scheduled end date and set the subscription to active.

Read subscription by id, via REST API Confirm card via saved PaymentIntent Card still usable? yes no, stay blocked Clear end date schedule_end = "" Set active add note
The card check comes before the write. The end date is cleared before the status change, in that order, because the second write depends on the first.

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 and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API, and make sure WooCommerce Subscriptions REST endpoints are enabled. 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 SUBSCRIPTION_ID="1234"
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 SUBSCRIPTION_ID="1234"
export DRY_RUN="true"   // start safe, change to false to write
2

Read the subscription and its last order

Load the subscription by id from the WooCommerce REST API, then find its most recent related order. That order is where the Stripe PaymentIntent id lives, saved in order meta as _stripe_intent_id, or occasionally as the order's transaction_id when it starts with pi_.

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 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 intent_id_of(order):
    for meta in (order or {}).get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = (order or {}).get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None
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, 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();
}

function intentIdOf(order) {
  for (const meta of (order || {}).meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = (order || {}).transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}
3

Check the saved payment method is still usable

Retrieve the PaymentIntent from Stripe and look at its status. Reactivating a subscription is not worth doing if the very next renewal is going to fail on a dead card, an expired card, or a payment method that was detached. Treat anything other than a healthy status as blocked, not fixed.

step3.py
import stripe

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
USABLE_CARD_STATUSES = {"succeeded", "requires_capture"}

def get_payment_method(intent_id):
    if not intent_id:
        return None
    try:
        intent = stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
    return {"status": intent.get("status"), "payment_method": intent.get("payment_method")}
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const USABLE_CARD_STATUSES = new Set(["succeeded", "requires_capture"]);

async function getPaymentMethod(intentId) {
  if (!intentId) return null;
  try {
    const intent = await stripe.paymentIntents.retrieve(intentId);
    return { status: intent.status, payment_method: intent.payment_method };
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the subscription, the last order, and the payment method check, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. Only a subscription that is actually pending-cancel, with a confirmed usable saved card, gets marked ready to repair. Everything else is either skipped or left blocked for a human to look at.

decide.py
REACTIVATABLE_FROM = {"pending-cancel", "on-hold"}
USABLE_CARD_STATUSES = {"succeeded", "requires_capture"}

def decide(subscription, last_order, payment_method):
    if subscription is None:
        return ("skip", "subscription not found")
    status = subscription.get("status")
    if status not in REACTIVATABLE_FROM:
        return ("skip", "subscription is not in a reactivatable state")
    if status == "on-hold":
        return ("skip", "on-hold is a separate case, not covered here")

    intent_id = intent_id_of(last_order)
    if not intent_id:
        return ("blocked", "no saved PaymentIntent to confirm the card still works")
    if payment_method is None:
        return ("blocked", "could not read the saved payment method from Stripe")
    if payment_method.get("status") not in USABLE_CARD_STATUSES:
        return ("blocked", "saved payment method is not currently usable")

    schedule_end = subscription.get("schedule_end") or ""
    if not schedule_end:
        return ("repair", "no leftover end date, just flip status to active")
    return ("repair", "leftover end date is blocking reactivation, clear it then activate")
decide.js
const REACTIVATABLE_FROM = new Set(["pending-cancel", "on-hold"]);
const USABLE_CARD_STATUSES = new Set(["succeeded", "requires_capture"]);

export function decide(subscription, lastOrder, paymentMethod) {
  if (!subscription) return ["skip", "subscription not found"];
  const status = subscription.status;
  if (!REACTIVATABLE_FROM.has(status)) return ["skip", "subscription is not in a reactivatable state"];
  if (status === "on-hold") return ["skip", "on-hold is a separate case, not covered here"];

  const intentId = intentIdOf(lastOrder);
  if (!intentId) return ["blocked", "no saved PaymentIntent to confirm the card still works"];
  if (!paymentMethod) return ["blocked", "could not read the saved payment method from Stripe"];
  if (!USABLE_CARD_STATUSES.has(paymentMethod.status)) {
    return ["blocked", "saved payment method is not currently usable"];
  }

  const scheduleEnd = subscription.schedule_end || "";
  if (!scheduleEnd) return ["repair", "no leftover end date, just flip status to active"];
  return ["repair", "leftover end date is blocking reactivation, clear it then activate"];
}
5

Clear the end date, then set status to active

This is two writes, in order, on purpose. WooCommerce Subscriptions re-checks whether a status change is allowed on every request, so the scheduled end date has to be gone before the status field flips. Sending both fields in a single request risks the same rejection the second write is meant to avoid. Then add a note so whoever looks at the subscription later can see exactly what happened and why.

apply.py
def reactivate(subscription_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"schedule_end": ""},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"status": "active"},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": "Reactivated by the pending-cancel repair script. Cleared the "
                      "scheduled end date and confirmed the saved payment method "
                      "before setting status back to active."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function reactivate(subscriptionId) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({ schedule_end: "" }),
  });
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({ status: "active" }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Reactivated by the pending-cancel repair script. Cleared the scheduled " +
            "end date and confirmed the saved payment method before setting status " +
            "back to active.",
    }),
  });
}
6

Wire it together with a dry run guard

The run function ties every piece together for a single subscription id. On the first few runs, leave DRY_RUN on so the script only logs what it would do. Read the output, trust it, then switch it off to let it write. Because this only touches one subscription at a time, it fits well as a support tool you run when a ticket comes in, not something you schedule blindly across every subscription in the store.

Run it safe

Always start with DRY_RUN=true. This script writes to a real subscription and its billing schedule, so you want to see its plan before it acts. Check the card status it reports lines up with what you expect, then turn dry run off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and refuses to touch a subscription whose saved payment method it cannot confirm is usable.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

reactivate_pending_cancel.py
"""Restore a WooCommerce Subscription that is stuck on pending-cancel back to active.

A subscription in pending-cancel status carries a scheduled "end" date (the date it
will fully cancel at the end of the paid term). WooCommerce Subscriptions will not let
you set status back to active while that end date is still on the subscription,
because the status machine treats "has a pending cancellation date" as a reason to
block a direct jump to active. The fix is not to force the status field. It is to
clear the scheduled end date first, confirm the saved payment method still works with
Stripe, and only then move the subscription to active, the same order a support agent
would do it by hand in wp-admin. Read only unless DRY_RUN=false. Run once per
subscription id, or loop it over a list of ids from a report.
"""
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_pending_cancel")

stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
    os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
    os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

REACTIVATABLE_FROM = {"pending-cancel", "on-hold"}
USABLE_CARD_STATUSES = {"succeeded", "requires_capture"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in (order or {}).get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = (order or {}).get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def decide(subscription, last_order, payment_method):
    """Pure decision function. No I/O. Returns (action, reason)."""
    if subscription is None:
        return ("skip", "subscription not found")
    status = subscription.get("status")
    if status not in REACTIVATABLE_FROM:
        return ("skip", "subscription is not in a reactivatable state")
    if status == "on-hold":
        return ("skip", "on-hold is a separate case, not covered here")

    intent_id = intent_id_of(last_order)
    if not intent_id:
        return ("blocked", "no saved PaymentIntent to confirm the card still works")
    if payment_method is None:
        return ("blocked", "could not read the saved payment method from Stripe")
    if payment_method.get("status") not in USABLE_CARD_STATUSES:
        return ("blocked", "saved payment method is not currently usable")

    schedule_end = subscription.get("schedule_end") or ""
    if not schedule_end:
        return ("repair", "no leftover end date, just flip status to active")
    return ("repair", "leftover end date is blocking reactivation, clear it then activate")


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 get_last_order(subscription):
    related = subscription.get("related_orders") or []
    order_id = related[-1] if related else subscription.get("last_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 get_payment_method(intent_id):
    if not intent_id:
        return None
    try:
        intent = stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
    return {"status": intent.get("status"), "payment_method": intent.get("payment_method")}


def reactivate(subscription_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"schedule_end": ""},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"status": "active"},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": "Reactivated by the pending-cancel repair script. Cleared the "
                      "scheduled end date and confirmed the saved payment method "
                      "before setting status back to active."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run(subscription_id):
    subscription = get_subscription(subscription_id)
    last_order = get_last_order(subscription) if subscription else None
    payment_method = get_payment_method(intent_id_of(last_order))
    action, reason = decide(subscription, last_order, payment_method)

    if action == "skip":
        log.info("Subscription %s: %s", subscription_id, reason)
        return
    if action == "blocked":
        log.warning("Subscription %s stayed pending-cancel: %s", subscription_id, reason)
        return

    log.info("Subscription %s: %s. %s", subscription_id, reason, "would reactivate" if DRY_RUN else "reactivating")
    if not DRY_RUN:
        reactivate(subscription_id)


if __name__ == "__main__":
    sub_id = os.environ.get("SUBSCRIPTION_ID")
    if not sub_id:
        raise SystemExit("Set SUBSCRIPTION_ID to the subscription post id to check")
    run(sub_id)
reactivate-pending-cancel.js
/**
 * Restore a WooCommerce Subscription that is stuck on pending-cancel back to active.
 *
 * A subscription in pending-cancel status carries a scheduled "end" date (the date it
 * will fully cancel at the end of the paid term). WooCommerce Subscriptions will not
 * let you set status back to active while that end date is still on the subscription,
 * because the status machine treats "has a pending cancellation date" as a reason to
 * block a direct jump to active. The fix is not to force the status field. It is to
 * clear the scheduled end date first, confirm the saved payment method still works
 * with Stripe, and only then move the subscription to active, the same order a
 * support agent would do it by hand in wp-admin. Read only unless DRY_RUN=false.
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const REACTIVATABLE_FROM = new Set(["pending-cancel", "on-hold"]);
const USABLE_CARD_STATUSES = new Set(["succeeded", "requires_capture"]);

export function intentIdOf(order) {
  for (const meta of (order || {}).meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = (order || {}).transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function decide(subscription, lastOrder, paymentMethod) {
  if (!subscription) return ["skip", "subscription not found"];
  const status = subscription.status;
  if (!REACTIVATABLE_FROM.has(status)) return ["skip", "subscription is not in a reactivatable state"];
  if (status === "on-hold") return ["skip", "on-hold is a separate case, not covered here"];

  const intentId = intentIdOf(lastOrder);
  if (!intentId) return ["blocked", "no saved PaymentIntent to confirm the card still works"];
  if (!paymentMethod) return ["blocked", "could not read the saved payment method from Stripe"];
  if (!USABLE_CARD_STATUSES.has(paymentMethod.status)) {
    return ["blocked", "saved payment method is not currently usable"];
  }

  const scheduleEnd = subscription.schedule_end || "";
  if (!scheduleEnd) return ["repair", "no leftover end date, just flip status to active"];
  return ["repair", "leftover end date is blocking reactivation, clear it then activate"];
}

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 getSubscription(subscriptionId) {
  return woo(`/subscriptions/${subscriptionId}`);
}

async function getLastOrder(subscription) {
  const related = subscription.related_orders || [];
  const orderId = related.length ? related[related.length - 1] : subscription.last_order_id;
  if (!orderId) return null;
  return woo(`/orders/${orderId}`);
}

async function getPaymentMethod(intentId) {
  if (!intentId) return null;
  try {
    const intent = await stripe.paymentIntents.retrieve(intentId);
    return { status: intent.status, payment_method: intent.payment_method };
  } catch {
    return null;
  }
}

async function reactivate(subscriptionId) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({ schedule_end: "" }),
  });
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({ status: "active" }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Reactivated by the pending-cancel repair script. Cleared the scheduled " +
            "end date and confirmed the saved payment method before setting status " +
            "back to active.",
    }),
  });
}

export async function run(subscriptionId) {
  const subscription = await getSubscription(subscriptionId);
  const lastOrder = subscription ? await getLastOrder(subscription) : null;
  const paymentMethod = await getPaymentMethod(intentIdOf(lastOrder));
  const [action, reason] = decide(subscription, lastOrder, paymentMethod);

  if (action === "skip") {
    console.log(`Subscription ${subscriptionId}: ${reason}`);
    return;
  }
  if (action === "blocked") {
    console.warn(`Subscription ${subscriptionId} stayed pending-cancel: ${reason}`);
    return;
  }

  console.log(`Subscription ${subscriptionId}: ${reason}. ${DRY_RUN ? "would reactivate" : "reactivating"}`);
  if (!DRY_RUN) await reactivate(subscriptionId);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const subId = process.env.SUBSCRIPTION_ID;
  if (!subId) {
    console.error("Set SUBSCRIPTION_ID to the subscription post id to check");
    process.exit(1);
  }
  run(subId).catch((e) => { console.error(e); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a real subscription and its billing schedule get touched. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_reactivate_decide.py
from reactivate_pending_cancel import decide, intent_id_of


def order(**over):
    base = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_1"}], "transaction_id": ""}
    base.update(over)
    return base


def sub(**over):
    base = {"status": "pending-cancel", "schedule_end": "2026-08-01T00:00:00"}
    base.update(over)
    return base


def method(**over):
    base = {"status": "succeeded", "payment_method": "pm_1"}
    base.update(over)
    return base


def test_repair_when_pending_cancel_and_card_ok():
    action, _ = decide(sub(), order(), method())
    assert action == "repair"


def test_skip_when_subscription_missing():
    assert decide(None, order(), method())[0] == "skip"


def test_skip_when_status_not_reactivatable():
    assert decide(sub(status="active"), order(), method())[0] == "skip"


def test_blocked_when_no_saved_intent():
    action, reason = decide(sub(), order(meta_data=[], transaction_id=""), method())
    assert action == "blocked"


def test_blocked_when_card_not_usable():
    action, reason = decide(sub(), order(), method(status="requires_payment_method"))
    assert action == "blocked"
reactivate-pending-cancel.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./reactivate-pending-cancel.js";

const order = (over = {}) => ({
  meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }],
  transaction_id: "",
  ...over,
});
const sub = (over = {}) => ({ status: "pending-cancel", schedule_end: "2026-08-01T00:00:00", ...over });
const method = (over = {}) => ({ status: "succeeded", payment_method: "pm_1", ...over });

test("repair when pending-cancel and card ok", () => {
  assert.equal(decide(sub(), order(), method())[0], "repair");
});

test("skip when subscription missing", () => {
  assert.equal(decide(null, order(), method())[0], "skip");
});

test("skip when status not reactivatable", () => {
  assert.equal(decide(sub({ status: "active" }), order(), method())[0], "skip");
});

test("blocked when no saved intent", () => {
  assert.equal(decide(sub(), order({ meta_data: [], transaction_id: "" }), method())[0], "blocked");
});

test("blocked when card not usable", () => {
  assert.equal(decide(sub(), order(), method({ status: "requires_payment_method" }))[0], "blocked");
});

Case studies

Support macro

The reactivate button that never actually worked

A store's helpdesk had a saved macro that called the WooCommerce REST API to set a subscription back to active whenever a customer asked to keep it. Support agents used it for months without noticing it silently failed on any subscription that was pending-cancel, because the leftover end date rejected the write every time.

Adding the two-step clear-then-activate sequence fixed every case going forward, and a short backfill run through the same script cleaned up the backlog of subscriptions still sitting on pending-cancel from missed reactivation requests.

Expired card

The reactivation that would have just failed again

A customer asked to undo a cancellation two days before the scheduled end date. A quick status flip would have looked successful, but the saved card on file had expired the month before, meaning the very next renewal was going to fail anyway and land the subscription back on-hold.

The script's payment method check caught it before writing anything, leaving the subscription blocked with a clear reason. Support asked the customer for a new card, then reran the script and it went through cleanly.

What good looks like

After this fix, "cannot reactivate" stops being a dead end. A subscription that is genuinely still payable moves back to active in two clean writes, with a note explaining exactly what changed. A subscription with a bad card stays blocked on purpose, with a clear reason, instead of silently failing again on the next renewal.

FAQ

Why can't I reactivate a subscription that is stuck on pending-cancel?

A pending-cancel subscription still carries a scheduled end date, the date it will fully cancel at the end of the paid term. WooCommerce Subscriptions blocks a direct jump from pending-cancel to active while that end date is still set. Clear the scheduled end date first, then set the status to active, and it goes through.

Is it safe to reactivate a subscription with a script?

Yes, when the script first confirms the customer's saved payment method still works with Stripe, since reactivating a subscription with a dead card just creates a fresh renewal failure. Start in dry run mode to see the plan before anything writes.

Will clearing the end date affect billing?

No. Clearing the scheduled end date only removes the future cancellation date. The next payment date on the subscription is untouched, so the customer is billed on the same schedule as before, they just keep their subscription instead of losing it at the end of the term.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: subscription statuses, including pending-cancel and how it relates to the scheduled end date. woocommerce.com/document/subscriptions/statuses
  2. WooCommerce Subscriptions developer docs: reactivating a subscription and the allowed status transitions. woocommerce.com/document/subscriptions/develop/functions
  3. WooCommerce community forum thread: subscription will not reactivate from pending cancellation. wordpress.org/support/plugin/woocommerce-subscriptions

On the solution:

  1. WooCommerce REST API: retrieve and update a subscription, including the schedule_end field. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: retrieve a PaymentIntent and read its status before reusing a saved payment method. docs.stripe.com/api/payment_intents/retrieve
  3. WooCommerce REST API: add a note to an order or subscription for an audit trail. 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 get your subscription unstuck?

If this saved you a support ticket or a lost customer, 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