Repair Cancellation sync
WooCommerce Subscriptions still try to bill after WooPayments is turned off
You disabled WooPayments, maybe to switch to a different processor or to pause the store, and assumed billing stopped with it. It did not. WooCommerce Subscriptions keeps its own renewal schedule, and every subscription still points at the payment method it was created with. The scheduled action fires anyway, tries to charge through a gateway that no longer exists, and fails quietly. Here is why that happens and a small script that finds every subscription still wired to the disabled gateway and moves it to manual renewal so it stops trying.
Turning off WooPayments does not touch existing subscriptions. Each one still has payment_method: woocommerce_payments saved on it, and WooCommerce Subscriptions will keep trying to run automatic renewals through that gateway even though it is disabled, so every attempt fails. Run a small Python or Node.js script that lists active and on-hold subscriptions through the WooCommerce REST API, finds the ones still set to the disabled gateway, and sets requires_manual_renewal to true so the automatic charge attempt stops. Full code, tests, and a dry run guard are below.
The problem in plain words
WooPayments is a payment gateway plugin. WooCommerce Subscriptions is a separate plugin that manages renewal dates and billing schedules. They work together, but one does not control the other. When you disable WooPayments in WooCommerce, Settings, Payments, you have only removed it from the list of gateways a customer can choose at checkout. You have not told any existing subscription to stop using it.
Every subscription that was created while WooPayments was active still has that gateway saved as its payment_method. WooCommerce Subscriptions runs a scheduled action for each subscription's next payment date regardless of which gateways are currently enabled. When that action fires, it asks the saved gateway to process the renewal. Since the gateway is disabled, the charge cannot go through, and the renewal fails, over and over, on every future due date, usually with no clear alert to anyone until a customer notices they were never billed, or worse, notices a failed payment retry that they did not expect.
Why it happens
The WooCommerce Subscriptions documentation is direct about this: automatic renewals depend on the payment gateway saved on the subscription supporting scheduled payments, and if that gateway is deactivated, renewals will fail. A few ways stores end up here:
- WooPayments was disabled to migrate to a different processor, but the migration only updated the checkout settings, not the existing subscriptions.
- A WooPayments account was suspended or deactivated by the payments provider for a compliance reason, which silently disables the gateway from the store's point of view.
- A staging or test site had WooPayments turned off, but production subscription data was cloned in alongside it, so every imported subscription still references the now-missing gateway.
- The store deliberately paused card billing during a rebuild, then re-enabled WooPayments weeks later without checking whether the paused subscriptions had piled up failed renewal attempts in the meantime.
WooCommerce Subscriptions does have a setting, requires_manual_renewal, that is meant for exactly this situation. It tells the scheduler "do not attempt an automatic charge, wait for the customer or the shop manager to pay manually." Nothing sets that flag automatically when a gateway is disabled, which is the gap this fix closes.
A subscription's payment method is a fact stored on the subscription, not a live check against your current settings. Disabling a gateway changes what a customer can pick tomorrow. It does nothing to a subscription that already picked it last year. The fix is not to reconnect the old gateway, it is to tell every affected subscription to stop trying automatically until a person or a new payment method takes over.
The fix, as a flow
We do not touch pricing, the next payment date, or line items. We add a script that lists subscriptions in a state where a renewal could still fire, checks whether the saved payment method is the disabled gateway, and if so, flips requires_manual_renewal to true. That one flag is what stops WooCommerce Subscriptions from trying to auto charge. If the most recent renewal order already tried and failed, we also read its Stripe PaymentIntent so the report shows exactly what happened, without changing that order.
Build it step by step
Get access and name the disabled gateway
You need a WooCommerce REST API key pair (consumer key and secret) with read and write access to subscriptions, plus a Stripe secret key if you want the report to show what a failed renewal attempt actually returned. List the gateway ids you disabled, usually just woocommerce_payments, in an environment variable so the script knows what to look for.
pip install stripe requests
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export STRIPE_SECRET_KEY="sk_live_..."
export DISABLED_GATEWAYS="woocommerce_payments"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export STRIPE_SECRET_KEY="sk_live_..."
export DISABLED_GATEWAYS="woocommerce_payments"
export DRY_RUN="true" // start safe, change to false to write
List subscriptions that could still try to bill
Only active and on-hold subscriptions run automatic renewals, so those are the only statuses worth checking. We page through the WooCommerce REST API rather than querying the database directly, which keeps the script working the same way whether the store uses classic order storage or High Performance Order Storage (HPOS), since WooCommerce Subscriptions is built on top of the same order tables.
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 billable_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,on-hold", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
yield sub
page += 1
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* billableSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
if (!batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
Read the last renewal attempt from Stripe, if there was one
If the most recent renewal order already tried to charge, its PaymentIntent id is usually saved in order meta as _stripe_intent_id, or as the order's transaction_id when it starts with pi_. Reading it back from Stripe tells us whether the failure was the gateway being gone entirely, versus a normal decline, which matters for the note we leave on the subscription.
import stripe
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
def get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
export function intentIdOf(order) {
for (const meta of (order && order.meta_data) || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order && order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
async function getIntent(stripe, intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
Decide, with one pure function
The decision only needs the subscription and the list of disabled gateway ids. It never needs the network, so it is easy to test on its own. Skip subscriptions that already require manual renewal, skip ones on a gateway that is still enabled, and mark the rest for repair.
BILLABLE_STATUSES = {"active", "on-hold"}
def is_manual(subscription):
value = subscription.get("requires_manual_renewal")
return value in (True, "true", 1, "1")
def decide(subscription, disabled_gateways):
if subscription["status"] not in BILLABLE_STATUSES:
return ("skip", "subscription is not billable")
if is_manual(subscription):
return ("skip", "already set to manual renewal")
method = subscription.get("payment_method") or ""
if method not in disabled_gateways:
return ("skip", "payment method is not a disabled gateway")
return ("repair", f"payment method '{method}' is disabled, would auto bill and fail")
const BILLABLE_STATUSES = new Set(["active", "on-hold"]);
export function isManual(subscription) {
const value = subscription.requires_manual_renewal;
return value === true || value === "true" || value === 1 || value === "1";
}
export function decide(subscription, disabledGateways) {
if (!BILLABLE_STATUSES.has(subscription.status)) return ["skip", "subscription is not billable"];
if (isManual(subscription)) return ["skip", "already set to manual renewal"];
const method = subscription.payment_method || "";
if (!disabledGateways.includes(method)) return ["skip", "payment method is not a disabled gateway"];
return ["repair", `payment method '${method}' is disabled, would auto bill and fail`];
}
Flip the flag and leave a note
When the action is repair, update the subscription with requires_manual_renewal: true through the REST API. Nothing else on the subscription changes, not the price, not the next payment date, not the line items. Add a note explaining why, so the shop manager or the customer support team sees the reason if they open the subscription later.
def set_manual_renewal(subscription_id, method):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"requires_manual_renewal": True},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Moved to manual renewal. Payment method '{method}' is disabled, "
f"so automatic renewal would keep failing. Price and next payment "
f"date were not changed."},
auth=AUTH, timeout=30,
).raise_for_status()
async function setManualRenewal(subscriptionId, method) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ requires_manual_renewal: true }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Moved to manual renewal. Payment method '${method}' is disabled, ` +
`so automatic renewal would keep failing. Price and next payment date were not changed.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Leave DRY_RUN on for the first pass so the script only reports what it would repair. Read the list, confirm the payment method and status look right, then switch it off. Run it once right after disabling the gateway, then again daily for a week or two in case a renewal was already queued.
Always start with DRY_RUN=true. This script changes a real billing setting on live subscriptions, so you want to see the exact list before it writes anything.
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, since it skips any subscription that is already on manual renewal or already on a gateway that is still enabled.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Stop WooCommerce Subscriptions from trying to auto bill through a gateway
you have disabled, such as WooPayments. Moves affected subscriptions to
manual renewal without changing price, next payment date, or line items.
Safe to run again and again.
"""
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("stop_billing_on_disabled_gateway")
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"])
DISABLED_GATEWAYS = [g.strip() for g in os.environ.get("DISABLED_GATEWAYS", "woocommerce_payments").split(",") if g.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
BILLABLE_STATUSES = {"active", "on-hold"}
def is_manual(subscription):
value = subscription.get("requires_manual_renewal")
return value in (True, "true", 1, "1")
def decide(subscription, disabled_gateways):
if subscription["status"] not in BILLABLE_STATUSES:
return ("skip", "subscription is not billable")
if is_manual(subscription):
return ("skip", "already set to manual renewal")
method = subscription.get("payment_method") or ""
if method not in disabled_gateways:
return ("skip", "payment method is not a disabled gateway")
return ("repair", f"payment method '{method}' is disabled, would auto bill and fail")
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
def get_intent(intent_id):
if not intent_id or not stripe.api_key:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def billable_subscriptions():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "active,on-hold", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for sub in batch:
yield sub
page += 1
def set_manual_renewal(subscription_id, method):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"requires_manual_renewal": True},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Moved to manual renewal. Payment method '{method}' is disabled, "
f"so automatic renewal would keep failing. Price and next payment "
f"date were not changed."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
repaired = 0
for subscription in billable_subscriptions():
action, reason = decide(subscription, DISABLED_GATEWAYS)
if action != "repair":
continue
last_order = subscription.get("last_order")
intent = get_intent(intent_id_of(last_order)) if isinstance(last_order, dict) else None
detail = f" Last attempt on Stripe: {intent['status']}." if intent else ""
log.warning("Subscription %s: %s.%s %s", subscription["id"], reason, detail,
"would repair" if DRY_RUN else "repairing")
if not DRY_RUN:
set_manual_renewal(subscription["id"], subscription.get("payment_method") or "")
repaired += 1
log.info("Done. %d subscription(s) %s.", repaired, "to repair" if DRY_RUN else "repaired")
if __name__ == "__main__":
run()
/**
* Stop WooCommerce Subscriptions from trying to auto bill through a gateway
* you have disabled, such as WooPayments. Moves affected subscriptions to
* manual renewal without changing price, next payment date, or line items.
* Safe to run again and again.
*/
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.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const DISABLED_GATEWAYS = (process.env.DISABLED_GATEWAYS || "woocommerce_payments")
.split(",").map((g) => g.trim()).filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const BILLABLE_STATUSES = new Set(["active", "on-hold"]);
export function isManual(subscription) {
const value = subscription.requires_manual_renewal;
return value === true || value === "true" || value === 1 || value === "1";
}
export function decide(subscription, disabledGateways) {
if (!BILLABLE_STATUSES.has(subscription.status)) return ["skip", "subscription is not billable"];
if (isManual(subscription)) return ["skip", "already set to manual renewal"];
const method = subscription.payment_method || "";
if (!disabledGateways.includes(method)) return ["skip", "payment method is not a disabled gateway"];
return ["repair", `payment method '${method}' is disabled, would auto bill and fail`];
}
export function intentIdOf(order) {
for (const meta of (order && order.meta_data) || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order && order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
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 getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function* billableSubscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active,on-hold&per_page=50&page=${page}`);
if (!batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
async function setManualRenewal(subscriptionId, method) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ requires_manual_renewal: true }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Moved to manual renewal. Payment method '${method}' is disabled, ` +
`so automatic renewal would keep failing. Price and next payment date were not changed.`,
}),
});
}
export async function run() {
let repaired = 0;
for await (const subscription of billableSubscriptions()) {
const [action, reason] = decide(subscription, DISABLED_GATEWAYS);
if (action !== "repair") continue;
const lastOrder = subscription.last_order;
const intent = lastOrder && typeof lastOrder === "object" ? await getIntent(intentIdOf(lastOrder)) : null;
const detail = intent ? ` Last attempt on Stripe: ${intent.status}.` : "";
console.warn(`Subscription ${subscription.id}: ${reason}.${detail} ${DRY_RUN ? "would repair" : "repairing"}`);
if (!DRY_RUN) await setManualRenewal(subscription.id, subscription.payment_method || "");
repaired++;
}
console.log(`Done. ${repaired} subscription(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part worth testing, since it decides which live subscriptions get changed. Because decide is pure, no Stripe account and no WooCommerce store are needed. It only takes plain objects and checks the action it returns.
from stop_billing_on_disabled_gateway import decide, is_manual, intent_id_of
def sub(**over):
base = {"status": "active", "payment_method": "woocommerce_payments", "requires_manual_renewal": False}
base.update(over)
return base
def test_repair_when_active_on_disabled_gateway():
assert decide(sub(), ["woocommerce_payments"])[0] == "repair"
def test_repair_when_on_hold_on_disabled_gateway():
assert decide(sub(status="on-hold"), ["woocommerce_payments"])[0] == "repair"
def test_skip_when_already_manual():
assert decide(sub(requires_manual_renewal=True), ["woocommerce_payments"])[0] == "skip"
def test_skip_when_gateway_not_disabled():
assert decide(sub(payment_method="stripe"), ["woocommerce_payments"])[0] == "skip"
def test_skip_when_not_billable_status():
assert decide(sub(status="cancelled"), ["woocommerce_payments"])[0] == "skip"
def test_is_manual_accepts_string_true():
assert is_manual({"requires_manual_renewal": "true"}) is True
def test_intent_id_from_meta():
order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
assert intent_id_of(order) == "pi_123"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, isManual, intentIdOf } from "./stop-billing-on-disabled-gateway.js";
const sub = (over = {}) => ({
status: "active", payment_method: "woocommerce_payments", requires_manual_renewal: false, ...over,
});
test("repair when active on disabled gateway", () => {
assert.equal(decide(sub(), ["woocommerce_payments"])[0], "repair");
});
test("repair when on-hold on disabled gateway", () => {
assert.equal(decide(sub({ status: "on-hold" }), ["woocommerce_payments"])[0], "repair");
});
test("skip when already manual", () => {
assert.equal(decide(sub({ requires_manual_renewal: true }), ["woocommerce_payments"])[0], "skip");
});
test("skip when gateway not disabled", () => {
assert.equal(decide(sub({ payment_method: "stripe" }), ["woocommerce_payments"])[0], "skip");
});
test("skip when not a billable status", () => {
assert.equal(decide(sub({ status: "cancelled" }), ["woocommerce_payments"])[0], "skip");
});
test("isManual accepts string true", () => {
assert.equal(isManual({ requires_manual_renewal: "true" }), true);
});
test("intentIdOf from meta", () => {
const order = { meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" };
assert.equal(intentIdOf(order), "pi_123");
});
Case studies
The store that switched processors and forgot the old subscriptions
A store moved from WooPayments to a different processor over a weekend. New checkouts worked fine. Three days later, forty renewal attempts had failed silently, all on subscriptions that predated the switch and still pointed at WooPayments.
Running the script in dry run listed all forty in seconds. Once switched to manual renewal, the team emailed each customer a payment link instead of leaving them to guess why a card was never charged.
The account that was suspended without warning
A WooPayments account was placed under review by the payments provider, which disabled the gateway from the store's side without any obvious banner in wp-admin. Renewals kept firing and failing for a week before support noticed the pattern in the logs.
The script's daily schedule caught new failures as they appeared and moved each affected subscription to manual renewal, so no further silent retries happened while the account review was in progress.
After this runs, a disabled gateway stops being an invisible source of failed renewals. Every subscription that was still wired to it is either already migrated to a working gateway or clearly marked as manual, with a note explaining why. Keep the script around for a week or two after any gateway change, since a renewal can already be queued before you disable anything.
FAQ
Why do subscriptions still try to bill after I turned off WooPayments?
WooCommerce Subscriptions schedules renewals independently of which gateways are active. Each subscription still remembers the payment method it was set up with, so the scheduled action fires and tries to charge through a gateway that is no longer enabled, which fails every time until someone updates the subscription.
Is it safe to switch these subscriptions to manual renewal with a script?
Yes, when the script only touches active or on-hold subscriptions whose payment method is the disabled gateway and it never changes the subscription price, next payment date, or line items. Setting requires_manual_renewal to true just stops the automatic charge attempt, it does not cancel or alter anything else.
Should I run this once or on a schedule?
Run it once right after disabling WooPayments to sweep every existing subscription, then leave it on a daily schedule for a week or two in case a renewal was already queued before the gateway was turned off.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: automatic renewals depend on the saved payment gateway being active and supporting scheduled payments. woocommerce.com/document/subscriptions/renewal-process
- WooCommerce Subscriptions docs: manual renewal payments and when a subscription is set to require them. woocommerce.com/document/subscriptions/manual-renewal-payments
- WooPayments docs: disabling or deactivating the WooPayments gateway from the store. woocommerce.com/document/woopayments/startup-guide
On the solution:
- WooCommerce Subscriptions REST API: read and update a subscription, including requires_manual_renewal. woocommerce.github.io/subscriptions-rest-api-docs
- Stripe API: retrieve a PaymentIntent by id to inspect the outcome of a past charge attempt. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: add an order or subscription note 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.
Did this stop your silent renewal failures?
If this saved you a pile of confused customers or a stretch of missed billing, 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