Repair Subscription lifecycle

A staging site paused real, paying subscriptions

Someone spun up a staging copy of the store to test a plugin update. It looked isolated. It was not. Staging kept the live WooCommerce API keys and the live Stripe secret key, and when its own renewal cron ran and failed for a reason that had nothing to do with the customer, WooCommerce Subscriptions paused the subscription it thought it had just tried to bill, and that subscription was the real one. Billing stopped for paying customers who did nothing wrong. Here is why it happens and a small script that finds the wrongly paused subscriptions and restores the ones Stripe confirms were actually paid.

Python and Node.js Runs on a schedule Safe by default (dry run)
Magazine covers on a wall
Photo by Rhamely on Unsplash
The short answer

A staging clone that shares the live Stripe secret key and the live WooCommerce REST API keys is not isolated, even if the site URL looks different. When staging tries to renew a subscription and the attempt fails there, WooCommerce Subscriptions sets the real, live subscription to On-Hold. Run a small Python or Node.js script that lists live subscriptions currently On-Hold, checks which host paused each one, and restores to Active only the ones where Stripe confirms the latest invoice is genuinely paid. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions decides whether a subscription is billed and active by asking one thing: did the last renewal payment succeed. It does not usually ask which site made that decision. If a staging environment is wired up to talk to the same live database, or worse, the same live WooCommerce REST API and the same live Stripe account as production, then as far as WooCommerce Subscriptions is concerned, staging is production.

So when a developer runs a cron job or clicks "renew now" on staging to test something, and that renewal attempt fails on staging (a stale test card, an expired key, a webhook pointed at the wrong environment), the plugin does exactly what it is built to do: it marks the subscription On-Hold. The trouble is, that subscription belongs to a real customer who is still being charged fine in the real world. Their access gets cut, and support has no idea why, because nothing changed on the live site itself.

Staging clone shares live keys Renewal cron runs on staging, by accident Charge fails there stale key or test card Live sub paused status set On-Hold No access customer was billed fine in the real world the whole time
Staging is not isolated once it shares live keys. A failure on staging still writes a status change to the real subscription.

Why it happens

This is a configuration mistake, not a WooCommerce bug, but it is a very easy one to make when a store gets cloned quickly under time pressure. A few common causes:

WooCommerce's own staging guidance is explicit that a cloned site should get fresh payment gateway keys and should not run scheduled tasks that touch live payment providers. When that step gets skipped, the two environments stop being separate in the way everyone assumes they are.

The key insight

Stripe still knows the truth. A subscription that was wrongly paused by staging did not actually fail to renew for the customer, so Stripe's own record of the latest invoice for that subscription will show it as paid. A repair script does not need to guess about staging's mess, it only needs to ask Stripe whether the money is really there before it touches anything.

The fix, as a flow

We do not try to stop staging from making the mistake in this script, that is a configuration fix on its own. Instead we add a job that looks at every subscription currently On-Hold, checks a small marker WooCommerce Subscriptions can record about which host last changed the status, and skips anything that was genuinely paused by the live site. For everything paused by another host, we ask Stripe for the truth. If the latest invoice for that subscription is paid, we restore it to Active and leave a note. If Stripe cannot confirm the payment, we leave it on hold for a person to look at rather than guess.

Scheduled job every few minutes List On-Hold subs from WooCommerce Read paused_by_host from meta Non-live host and invoice paid? yes no, hold for review Restore Active add a note
Only subscriptions paused by a non-live host and confirmed paid by Stripe get restored. Everything else is left alone or held for a person to check.

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. You also need to know your live site's hostname, so the script can tell a live pause apart from a staging one. 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 LIVE_SITE_HOST="yourstore.com"
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 LIVE_SITE_HOST="yourstore.com"
export DRY_RUN="true"   // start safe, change to false to write
2

List every subscription that is currently On-Hold

Page through the WooCommerce Subscriptions REST endpoint filtered to On-Hold. This is the full set of candidates. Most of these will turn out to be genuine failed payments and the script will leave them exactly as they are.

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 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
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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* onHoldSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=on-hold&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}
3

Find out which host paused it

WooCommerce Subscriptions lets you record a small piece of meta whenever a status change happens. If your renewal handler writes the current site's hostname into _paused_by_host when it pauses a subscription, this script can tell a staging pause apart from a live one. No marker at all means we genuinely do not know, so we leave that subscription alone rather than guess.

meta.py
def get_meta(obj, key):
    for m in obj.get("meta_data", []) or []:
        if m.get("key") == key:
            return m.get("value")
    return None

def paused_by_host(sub):
    return get_meta(sub, "_paused_by_host")
meta.js
export function getMeta(obj, key) {
  for (const m of obj.meta_data || []) {
    if (m.key === key) return m.value;
  }
  return null;
}

export function pausedByHost(sub) {
  return getMeta(sub, "_paused_by_host");
}
4

Ask Stripe whether the latest invoice was really paid

For any subscription paused by a non-live host, look up its Stripe subscription ID from meta and fetch the latest invoice. Stripe's record does not care which environment triggered the check. If the invoice status is paid, the customer was genuinely billed and the pause was a mistake.

invoice.py
import stripe

def get_latest_invoice(sub):
    sub_id = get_meta(sub, "_stripe_subscription_id") or get_meta(sub, "_wcpay_subscription_id")
    if not sub_id or not stripe.api_key:
        return None
    try:
        stripe_sub = stripe.Subscription.retrieve(sub_id, expand=["latest_invoice"])
    except stripe.error.InvalidRequestError:
        return None
    return stripe_sub.get("latest_invoice")
invoice.js
async function getLatestInvoice(sub) {
  const subId = getMeta(sub, "_stripe_subscription_id") || getMeta(sub, "_wcpay_subscription_id");
  if (!subId) return null;
  try {
    const stripeSub = await stripe.subscriptions.retrieve(subId, { expand: ["latest_invoice"] });
    return stripeSub.latest_invoice || null;
  } catch {
    return null;
  }
}
5

Decide, with one pure function

Keep the decision in its own function that takes the subscription, the Stripe invoice, and the live hostname, 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 subscriptions On-Hold are candidates. If we cannot tell who paused it, skip. If the live site paused it, skip, that is probably a real failed payment. If a non-live host paused it and Stripe confirms the invoice is paid, restore it. Otherwise, hold it for a human.

decide.py
RESTORABLE_FROM = {"on-hold"}

def decide(sub, latest_invoice, live_site_host):
    if sub.get("status") not in RESTORABLE_FROM:
        return ("skip", "subscription is not on-hold")

    host = paused_by_host(sub)
    if not host:
        return ("skip", "no record of what paused it, leave it for manual review")
    if host == live_site_host:
        return ("skip", "paused by the live site, likely a real failed payment")

    if latest_invoice is None:
        return ("hold", "paused by a non-live host but Stripe has no matching invoice")
    if latest_invoice.get("status") != "paid":
        return ("hold", "paused by a non-live host and Stripe invoice is not paid either")

    return ("restore", "paused by a non-live host, but Stripe shows the invoice paid")
decide.js
const RESTORABLE_FROM = new Set(["on-hold"]);

export function decide(sub, latestInvoice, liveSiteHost) {
  if (!RESTORABLE_FROM.has(sub.status)) {
    return ["skip", "subscription is not on-hold"];
  }

  const host = pausedByHost(sub);
  if (!host) return ["skip", "no record of what paused it, leave it for manual review"];
  if (host === liveSiteHost) return ["skip", "paused by the live site, likely a real failed payment"];

  if (!latestInvoice) return ["hold", "paused by a non-live host but Stripe has no matching invoice"];
  if (latestInvoice.status !== "paid") {
    return ["hold", "paused by a non-live host and Stripe invoice is not paid either"];
  }

  return ["restore", "paused by a non-live host, but Stripe shows the invoice paid"];
}
6

Restore the subscription and leave a note

When the action is restore, set the subscription back to Active and add a note explaining exactly why, including that it was a non-live host that paused it. This gives the shop manager a clear trail if the same thing happens again before staging is properly isolated.

apply.py
def restore(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": "This subscription was paused by a non-live host (likely a staging "
                      "copy that shared the live API and Stripe keys). Stripe confirms the "
                      "latest invoice is paid, so it was restored to Active by the reconciler."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function restore(subId) {
  await woo(`/subscriptions/${subId}`, {
    method: "PUT",
    body: JSON.stringify({ status: "active" }),
  });
  await woo(`/subscriptions/${subId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "This subscription was paused by a non-live host (likely a staging copy that " +
            "shared the live API and Stripe keys). Stripe confirms the latest invoice is " +
            "paid, so it was restored to Active by the reconciler.",
    }),
  });
}
Run it safe

Always start with DRY_RUN=true. This script restores billing access for real customers, so read its report first. Anything it cannot confirm as paid is held on-hold for a human, it never guesses in the direction of restoring access.

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 is safe to run again and again because it only ever restores a subscription once and never touches one the live site paused itself.

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

restore_wrongly_paused_subs.py
"""Restore live WooCommerce subscriptions that a staging site wrongly paused.

A staging copy of the store (built with a migration or backup plugin) can end up
pointed at the live WooCommerce REST API and the live Stripe account, usually
because the site URL was swapped but a saved API key or webhook target was not.
When staging's own cron runs subscription renewals, a mismatched key or a stale
test card makes the "payment" fail on staging, and WooCommerce Subscriptions
calls payment_failed() on the real, live subscription. The customer was never
actually charged for anything on staging, but their live subscription is now
On-Hold and billing has stopped.

This script finds subscriptions that were paused by a run that did not come from
the live site, confirms with Stripe that the most recent invoice for that
subscription is genuinely paid, and restores only those to Active. Safe to run
again and again. Read only until DRY_RUN is turned off.

Guide: https://www.allanninal.dev/woocommerce/staging-site-pauses-live-subs/
"""
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("restore_wrongly_paused_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"])
LIVE_SITE_HOST = os.environ.get("LIVE_SITE_HOST", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Statuses that count as "billing is active" once we restore.
RESTORABLE_FROM = {"on-hold"}


def get_meta(obj, key):
    """Read a value out of a WooCommerce meta_data list by key."""
    for m in obj.get("meta_data", []) or []:
        if m.get("key") == key:
            return m.get("value")
    return None


def paused_by_host(sub):
    """The hostname that last paused this subscription, if the pause recorded one.

    The staging clone writes its own hostname into `_paused_by_host` meta when it
    changes a subscription's status, the same way it would tag any other write.
    A missing value means we cannot tell where the pause came from, so we treat
    that as "unknown" rather than assume it is safe to touch.
    """
    return get_meta(sub, "_paused_by_host")


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


def decide(sub, latest_invoice, live_site_host):
    """Pure decision: should this subscription be restored to active?

    sub is a WooCommerce subscription resource (dict). latest_invoice is the
    Stripe invoice for the subscription's current billing period, or None if it
    could not be found or Stripe has no record of one. live_site_host is the
    production hostname, used to tell a staging-originated pause apart from a
    real one. Returns (action, reason) and never performs any I/O.
    """
    if sub.get("status") not in RESTORABLE_FROM:
        return ("skip", "subscription is not on-hold")

    host = paused_by_host(sub)
    if not host:
        return ("skip", "no record of what paused it, leave it for manual review")
    if host == live_site_host:
        return ("skip", "paused by the live site, likely a real failed payment")

    if latest_invoice is None:
        return ("hold", "paused by a non-live host but Stripe has no matching invoice")
    if latest_invoice.get("status") != "paid":
        return ("hold", "paused by a non-live host and Stripe invoice is not paid either")

    return ("restore", "paused by a non-live host, but Stripe shows the invoice 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_latest_invoice(sub):
    sub_id = get_meta(sub, "_stripe_subscription_id") or get_meta(sub, "_wcpay_subscription_id")
    if not sub_id or not stripe.api_key:
        return None
    try:
        stripe_sub = stripe.Subscription.retrieve(sub_id, expand=["latest_invoice"])
    except stripe.error.InvalidRequestError:
        return None
    return stripe_sub.get("latest_invoice")


def restore(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": "This subscription was paused by a non-live host (likely a staging "
                      "copy that shared the live API and Stripe keys). Stripe confirms the "
                      "latest invoice is paid, so it was restored to Active by the reconciler."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    restored = 0
    held = 0
    for sub in on_hold_subscriptions():
        invoice = get_latest_invoice(sub)
        action, reason = decide(sub, invoice, LIVE_SITE_HOST)
        if action == "skip":
            continue
        if action == "hold":
            log.warning("Subscription %s: %s. Left on-hold for manual review.", sub["id"], reason)
            held += 1
            continue
        log.info("Subscription %s: %s. %s", sub["id"], reason, "would restore" if DRY_RUN else "restoring")
        if not DRY_RUN:
            restore(sub["id"])
        restored += 1
    log.info("Done. %d subscription(s) %s, %d held for review.",
              restored, "to restore" if DRY_RUN else "restored", held)


if __name__ == "__main__":
    run()
restore-wrongly-paused-subs.js
/**
 * Restore live WooCommerce subscriptions that a staging site wrongly paused.
 *
 * A staging copy of the store (built with a migration or backup plugin) can end
 * up pointed at the live WooCommerce REST API and the live Stripe account,
 * usually because the site URL was swapped but a saved API key or webhook
 * target was not. When staging's own cron runs subscription renewals, a
 * mismatched key or a stale test card makes the "payment" fail on staging, and
 * WooCommerce Subscriptions calls payment_failed() on the real, live
 * subscription. The customer was never actually charged for anything on
 * staging, but their live subscription is now On-Hold and billing has stopped.
 *
 * This script finds subscriptions that were paused by a run that did not come
 * from the live site, confirms with Stripe that the most recent invoice for
 * that subscription is genuinely paid, and restores only those to Active. Safe
 * to run again and again. Read only until DRY_RUN is turned off.
 *
 * Guide: https://www.allanninal.dev/woocommerce/staging-site-pauses-live-subs/
 */
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 LIVE_SITE_HOST = process.env.LIVE_SITE_HOST || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Statuses that count as "billing is active" once we restore.
const RESTORABLE_FROM = new Set(["on-hold"]);

/** Read a value out of a WooCommerce meta_data list by key. */
export function getMeta(obj, key) {
  for (const m of obj.meta_data || []) {
    if (m.key === key) return m.value;
  }
  return null;
}

/**
 * The hostname that last paused this subscription, if the pause recorded one.
 *
 * The staging clone writes its own hostname into `_paused_by_host` meta when it
 * changes a subscription's status, the same way it would tag any other write.
 * A missing value means we cannot tell where the pause came from, so we treat
 * that as "unknown" rather than assume it is safe to touch.
 */
export function pausedByHost(sub) {
  return getMeta(sub, "_paused_by_host");
}

/** The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id. */
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;
}

/**
 * Pure decision: should this subscription be restored to active?
 *
 * sub is a WooCommerce subscription resource (object). latestInvoice is the
 * Stripe invoice for the subscription's current billing period, or null if it
 * could not be found or Stripe has no record of one. liveSiteHost is the
 * production hostname, used to tell a staging-originated pause apart from a
 * real one. Returns [action, reason] and never performs any I/O.
 */
export function decide(sub, latestInvoice, liveSiteHost) {
  if (!RESTORABLE_FROM.has(sub.status)) {
    return ["skip", "subscription is not on-hold"];
  }

  const host = pausedByHost(sub);
  if (!host) return ["skip", "no record of what paused it, leave it for manual review"];
  if (host === liveSiteHost) return ["skip", "paused by the live site, likely a real failed payment"];

  if (!latestInvoice) return ["hold", "paused by a non-live host but Stripe has no matching invoice"];
  if (latestInvoice.status !== "paid") {
    return ["hold", "paused by a non-live host and Stripe invoice is not paid either"];
  }

  return ["restore", "paused by a non-live host, but Stripe shows the invoice paid"];
}

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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* onHoldSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=on-hold&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}

async function getLatestInvoice(sub) {
  const subId = getMeta(sub, "_stripe_subscription_id") || getMeta(sub, "_wcpay_subscription_id");
  if (!subId) return null;
  try {
    const stripeSub = await stripe.subscriptions.retrieve(subId, { expand: ["latest_invoice"] });
    return stripeSub.latest_invoice || null;
  } catch {
    return null;
  }
}

async function restore(subId) {
  await woo(`/subscriptions/${subId}`, {
    method: "PUT",
    body: JSON.stringify({ status: "active" }),
  });
  await woo(`/subscriptions/${subId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "This subscription was paused by a non-live host (likely a staging copy that " +
            "shared the live API and Stripe keys). Stripe confirms the latest invoice is " +
            "paid, so it was restored to Active by the reconciler.",
    }),
  });
}

export async function run() {
  let restored = 0;
  let held = 0;
  for await (const sub of onHoldSubscriptions()) {
    const invoice = await getLatestInvoice(sub);
    const [action, reason] = decide(sub, invoice, LIVE_SITE_HOST);
    if (action === "skip") continue;
    if (action === "hold") {
      console.warn(`Subscription ${sub.id}: ${reason}. Left on-hold for manual review.`);
      held++;
      continue;
    }
    console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would restore" : "restoring"}`);
    if (!DRY_RUN) await restore(sub.id);
    restored++;
  }
  console.log(`Done. ${restored} subscription(s) ${DRY_RUN ? "to restore" : "restored"}, ${held} held for review.`);
}

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 customers get their billing restored or left frozen. 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_staging_pause_decide.py
from restore_wrongly_paused_subs import decide, paused_by_host

LIVE_HOST = "shop.example.com"


def sub(**over):
    base = {
        "status": "on-hold",
        "meta_data": [{"key": "_paused_by_host", "value": "staging.example.com"}],
    }
    base.update(over)
    return base


def invoice(**over):
    base = {"status": "paid"}
    base.update(over)
    return base


def test_restore_when_paused_by_staging_and_invoice_paid():
    assert decide(sub(), invoice(), LIVE_HOST)[0] == "restore"


def test_skip_when_not_on_hold():
    assert decide(sub(status="active"), invoice(), LIVE_HOST)[0] == "skip"


def test_skip_when_no_host_recorded():
    s = sub(meta_data=[])
    assert decide(s, invoice(), LIVE_HOST)[0] == "skip"


def test_skip_when_paused_by_the_live_site():
    s = sub(meta_data=[{"key": "_paused_by_host", "value": LIVE_HOST}])
    assert decide(s, invoice(), LIVE_HOST)[0] == "skip"


def test_hold_when_no_invoice_found():
    assert decide(sub(), None, LIVE_HOST)[0] == "hold"


def test_hold_when_invoice_not_paid():
    assert decide(sub(), invoice(status="open"), LIVE_HOST)[0] == "hold"
restore-wrongly-paused-subs.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, pausedByHost } from "./restore-wrongly-paused-subs.js";

const LIVE_HOST = "shop.example.com";

const sub = (over = {}) => ({
  status: "on-hold",
  meta_data: [{ key: "_paused_by_host", value: "staging.example.com" }],
  ...over,
});

const invoice = (over = {}) => ({ status: "paid", ...over });

test("restore when paused by staging and invoice paid", () => {
  assert.equal(decide(sub(), invoice(), LIVE_HOST)[0], "restore");
});

test("skip when not on-hold", () => {
  assert.equal(decide(sub({ status: "active" }), invoice(), LIVE_HOST)[0], "skip");
});

test("skip when no host recorded", () => {
  assert.equal(decide(sub({ meta_data: [] }), invoice(), LIVE_HOST)[0], "skip");
});

test("skip when paused by the live site", () => {
  const s = sub({ meta_data: [{ key: "_paused_by_host", value: LIVE_HOST }] });
  assert.equal(decide(s, invoice(), LIVE_HOST)[0], "skip");
});

test("hold when no invoice found", () => {
  assert.equal(decide(sub(), null, LIVE_HOST)[0], "hold");
});

test("hold when invoice not paid", () => {
  assert.equal(decide(sub(), invoice({ status: "open" }), LIVE_HOST)[0], "hold");
});

Case studies

Migration plugin

The clone that never got new keys

A store used a popular migration plugin to spin up a staging copy for a theme update. The plugin faithfully copied every option in the database, including the live Stripe secret key and the live WooCommerce REST API keys. Nobody rotated them on staging because nobody thought to.

A week later, a developer testing a subscription-related bug triggered a manual renewal on staging with an expired test card. WooCommerce Subscriptions paused the subscription. It was a real customer's, and their live account had been fine the whole time. The restore script found it within a day because Stripe confirmed the actual invoice was paid.

Leftover cron

The renewal that fired on the wrong server

A staging environment was left running after a project wrapped up, still pointed at the live Stripe account from an earlier test. WP-Cron on that idle staging box quietly kept trying to process subscription renewals using an old, revoked API key, and every attempt failed and paused another live subscription.

Eleven subscriptions were paused before anyone noticed the pattern. Running the script in dry run listed all eleven with the same non-live host recorded on each one, all confirmed paid by Stripe. The team ran it for real, restored access immediately, and shut down the stray staging server the same day.

What good looks like

Once this runs on a schedule, a staging misconfiguration becomes a short interruption instead of a lost customer. The real fix is giving staging its own Stripe test keys, its own WooCommerce REST API keys, and no path back to production, but until that is locked down everywhere, this script keeps real subscriptions from getting stuck on a mistake that was never theirs.

FAQ

Why did my staging site pause real, live subscriptions?

A staging clone that keeps the live WooCommerce REST API keys and the live Stripe secret key is not really isolated. When staging's own cron tries to renew a subscription and the charge fails there for any reason, WooCommerce Subscriptions pauses the subscription it thinks it just billed, and that subscription is the real, live one. The customer was never charged on staging, but their live billing stops.

Is it safe to restore a paused subscription with a script?

Yes, when the script first confirms which host paused it and confirms with Stripe that the subscription's latest invoice is genuinely paid. It only restores subscriptions that were paused by a non-live host and are provably paid, and it leaves everything else on hold for a human to check.

How do I stop this from happening again?

Give staging its own Stripe test keys and its own WooCommerce REST API keys, block outbound requests from staging to the live store's API, and disable WP-Cron on any environment that is not production. The restore script is a safety net, not a substitute for isolating staging properly.

Related field notes

Citations

On the problem:

  1. WooCommerce docs: setting up a staging site, including why gateway keys need to be replaced on the clone. woocommerce.com/document/how-to-test-for-conflicts
  2. WooCommerce Subscriptions docs: how automatic renewals and payment failures change a subscription's status. woocommerce.com/document/subscriptions/renewal-process
  3. WordPress docs: how WP-Cron schedules and fires tasks against whatever environment loads the site. developer.wordpress.org/plugins/cron

On the solution:

  1. Stripe API: retrieve a subscription and expand its latest invoice. docs.stripe.com/api/subscriptions/retrieve
  2. Stripe docs: invoice statuses and what "paid" actually confirms. docs.stripe.com/invoicing/overview
  3. WooCommerce Subscriptions REST API: list and update subscriptions and add notes. woocommerce.github.io/subscriptions-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 customers unstuck?

If this saved a batch of subscriptions from staying frozen, 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