Repair Manual renewal and dunning
WooCommerce subscriptions that reverted to manual renewal
Your subscriptions used to charge themselves. After an update or a gateway change, a batch of them quietly switched to manual renewal. Now WooCommerce emails an invoice and waits for the customer to pay by hand, and most of them do not. The card is still on file and perfectly good, but nothing charges it. Here is why active subscriptions flip to manual and a small job that turns automatic renewal back on for the ones that still have a token.
A gateway change or an update can set active subscriptions to require manual renewal even when they still hold a saved Stripe token, so they stop charging on their own and lapse. Run a small Python or Node.js job that walks active subscriptions, finds the ones set to manual renewal that still have a token in their meta, and turns automatic renewal back on. Full code, tests, and a dry run guard are below.
The problem in plain words
A WooCommerce subscription renews in one of two ways. Automatic renewal charges the saved card on its own every cycle. Manual renewal sends the customer an invoice and waits for them to pay by hand. For a subscription with a card on file, automatic is what you want.
Sometimes a subscription that was automatic gets switched to manual, even though the saved token is still there. WooCommerce then stops charging it. The customer gets an invoice they did not expect, ignores it, and the subscription lapses. Nothing looks broken from the outside, the money just stops coming in.
Why it happens
A subscription is automatic only when its gateway is set up for automatic payments and the renewal type is not manual. A few things can quietly break that:
- The store switched or reconfigured its payment gateway, and existing subscriptions were marked manual during the change.
- A plugin update changed how the gateway reports support for automatic payments, so WooCommerce treated the subscriptions as manual.
- A token migration moved the saved cards but left the manual renewal flag set.
- A staging import or a bulk edit flipped the renewal type by mistake.
In all of these the saved token is still there. The subscription could charge automatically, it has just been told not to. The fix is to find those and turn automatic renewal back on. See the citations at the end for the docs on renewal types.
A subscription with a saved token and manual renewal is a contradiction: it can charge, but it will not. Those are the ones to fix. By checking for the token first, we only flip the subscriptions that have a real way to charge, and never force automatic billing on one that has no card at all.
The fix, as a flow
We do not change the gateway or touch card details. We add a job that walks the active subscriptions, and for each one checks two things: is it set to manual renewal, and does it still have a saved token. When both are true, we turn automatic renewal back on so the next cycle charges the card as it should.
Build it step by step
Get a WooCommerce API key
You need a WooCommerce REST API key pair with read and write access. WooCommerce Subscriptions adds subscription endpoints to the same REST API. Create the key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install requests
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true" // start safe, change to false to write
Check for a saved token, with a pure function
The WooCommerce Stripe gateway saves a token reference on the subscription, usually as the meta _stripe_source_id or _stripe_customer_id. A small function reads the meta and says whether any token is present. Keeping it pure means we can test it with plain objects.
TOKEN_META_KEYS = ("_stripe_source_id", "_stripe_customer_id")
def has_saved_token(subscription):
meta = {m.get("key"): m.get("value") for m in subscription.get("meta_data") or []}
return any(meta.get(key) for key in TOKEN_META_KEYS)
const TOKEN_META_KEYS = ["_stripe_source_id", "_stripe_customer_id"];
export function hasSavedToken(subscription) {
const meta = {};
for (const m of subscription.meta_data || []) meta[m.key] = m.value;
return TOKEN_META_KEYS.some((key) => meta[key]);
}
Decide, with one more pure function
A subscription is wrongly manual when it is active, set to manual renewal, and still has a saved token. All three must be true. This keeps the rule tight so the job never forces automatic billing on a subscription that has no card, and never touches ones that are already automatic.
def is_wrongly_manual(subscription):
if subscription.get("status") != "active":
return False
if not subscription.get("requires_manual_renewal"):
return False
return has_saved_token(subscription)
export function isWronglyManual(subscription) {
if (subscription.status !== "active") return false;
if (!subscription.requires_manual_renewal) return false;
return hasSavedToken(subscription);
}
Turn automatic renewal back on
When a subscription is wrongly manual, update it to set requires_manual_renewal to false. Because the token is already on file, the next scheduled renewal will charge the card on its own, the way it did before. The update goes through the REST API, so it works with High Performance Order Storage.
def restore_auto(subscription_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"requires_manual_renewal": False}, auth=AUTH, timeout=30,
).raise_for_status()
async function restoreAuto(subscriptionId) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ requires_manual_renewal: false }),
});
}
Wire it together with a dry run guard
The loop ties every piece together, paging through the active subscriptions. On the first run, leave DRY_RUN on so the job only reports which subscriptions it would restore. Read the list, confirm those really should be automatic, then switch it off. A daily run keeps a wrongly manual subscription from missing more than one cycle.
Start with DRY_RUN=true. Only ever restore automatic renewal where a token exists, which this job does by checking first. If a customer genuinely chose manual renewal, leave that choice alone by making sure their subscription has no saved token, or by excluding it from the run.
The full code
Here is the complete job 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 subscriptions that are active, manual, and still hold a token.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Restore WooCommerce subscriptions that wrongly flipped to manual renewal.
Read only by default. Run on a schedule.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("restore_auto_renewal")
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"
TOKEN_META_KEYS = ("_stripe_source_id", "_stripe_customer_id")
def has_saved_token(subscription):
meta = {m.get("key"): m.get("value") for m in subscription.get("meta_data") or []}
return any(meta.get(key) for key in TOKEN_META_KEYS)
def is_wrongly_manual(subscription):
if subscription.get("status") != "active":
return False
if not subscription.get("requires_manual_renewal"):
return False
return has_saved_token(subscription)
def get(path, params=None):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3{path}", params=params or {}, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def restore_auto(subscription_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"requires_manual_renewal": False}, auth=AUTH, timeout=30,
).raise_for_status()
def subscriptions():
page = 1
while True:
batch = get("/subscriptions", {"status": "active", "per_page": 50, "page": page})
if not batch:
return
for subscription in batch:
yield subscription
page += 1
def run():
fixed = 0
for subscription in subscriptions():
if not is_wrongly_manual(subscription):
continue
log.warning("Subscription %s is manual but has a saved token. %s",
subscription["id"], "would restore auto" if DRY_RUN else "restoring auto")
if not DRY_RUN:
restore_auto(subscription["id"])
fixed += 1
log.info("Done. %d subscription(s) %s.", fixed, "to restore" if DRY_RUN else "restored")
if __name__ == "__main__":
run()
/**
* Restore WooCommerce subscriptions that wrongly flipped to manual renewal.
* Read only by default. Run on a schedule.
*/
import { pathToFileURL } from "node:url";
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 TOKEN_META_KEYS = ["_stripe_source_id", "_stripe_customer_id"];
export function hasSavedToken(subscription) {
const meta = {};
for (const m of subscription.meta_data || []) meta[m.key] = m.value;
return TOKEN_META_KEYS.some((key) => meta[key]);
}
export function isWronglyManual(subscription) {
if (subscription.status !== "active") return false;
if (!subscription.requires_manual_renewal) return false;
return hasSavedToken(subscription);
}
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* subscriptions() {
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
if (!batch.length) return;
for (const subscription of batch) yield subscription;
page++;
}
}
async function restoreAuto(subscriptionId) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ requires_manual_renewal: false }),
});
}
async function run() {
let fixed = 0;
for await (const subscription of subscriptions()) {
if (!isWronglyManual(subscription)) continue;
console.warn(`Subscription ${subscription.id} is manual but has a saved token. ${DRY_RUN ? "would restore auto" : "restoring auto"}`);
if (!DRY_RUN) await restoreAuto(subscription.id);
fixed++;
}
console.log(`Done. ${fixed} subscription(s) ${DRY_RUN ? "to restore" : "restored"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The token check and the decision rule are the parts most worth testing, because together they decide which subscriptions get flipped back to automatic. Because both are pure, the tests need no network and no store. They just feed in plain subscriptions and check the answer.
from restore_auto_renewal import is_wrongly_manual, has_saved_token
def sub(**over):
base = {
"status": "active",
"requires_manual_renewal": True,
"meta_data": [{"key": "_stripe_source_id", "value": "src_123"}],
}
base.update(over)
return base
def test_has_token_from_source_id():
assert has_saved_token(sub()) is True
def test_no_token_when_meta_empty():
assert has_saved_token(sub(meta_data=[])) is False
def test_wrongly_manual_when_active_manual_and_tokened():
assert is_wrongly_manual(sub()) is True
def test_not_flagged_when_already_automatic():
assert is_wrongly_manual(sub(requires_manual_renewal=False)) is False
def test_not_flagged_when_no_token():
assert is_wrongly_manual(sub(meta_data=[])) is False
def test_not_flagged_when_not_active():
assert is_wrongly_manual(sub(status="on-hold")) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isWronglyManual, hasSavedToken } from "./restore-auto-renewal.js";
const sub = (over = {}) => ({
status: "active",
requires_manual_renewal: true,
meta_data: [{ key: "_stripe_source_id", value: "src_123" }],
...over,
});
test("has token from source id", () => {
assert.equal(hasSavedToken(sub()), true);
});
test("no token when meta empty", () => {
assert.equal(hasSavedToken(sub({ meta_data: [] })), false);
});
test("wrongly manual when active, manual, and tokened", () => {
assert.equal(isWronglyManual(sub()), true);
});
test("not flagged when already automatic", () => {
assert.equal(isWronglyManual(sub({ requires_manual_renewal: false })), false);
});
test("not flagged when no token", () => {
assert.equal(isWronglyManual(sub({ meta_data: [] })), false);
});
test("not flagged when not active", () => {
assert.equal(isWronglyManual(sub({ status: "on-hold" })), false);
});
Case studies
The migration that muted a thousand renewals
A membership site moved to a new Stripe setup. The saved cards came across fine, but every active subscription was marked manual in the process. The next month a wave of members got invoices they never expected, and renewals dropped off a cliff.
The job found every active subscription that was manual with a token still on file and switched them back to automatic. The following cycle charged normally, and the drop reversed before most members noticed.
The update that changed a flag
After a gateway plugin update, a batch of subscriptions quietly started treating themselves as manual. Revenue dipped for a few weeks before anyone connected it to the update.
The team ran the job in dry run, saw the exact set of subscriptions that could charge but were set to manual, and restored them. They also kept the job on a daily schedule as a safety net for the next update.
After this runs on a schedule, a subscription that can charge does charge. A gateway change or an update can no longer quietly move your recurring revenue onto manual invoices that customers ignore. The only subscriptions left on manual renewal are the ones that truly have no saved card, which is exactly right.
FAQ
Why did my WooCommerce subscriptions switch to manual renewal?
A gateway change, a plugin update, or a token migration can set active subscriptions to require manual renewal, even when they still hold a saved payment token. Manual renewal means WooCommerce stops charging them automatically, so they wait for the customer to pay and often lapse. Turning automatic renewal back on for the ones that still have a token fixes it.
What is the difference between manual and automatic renewal?
With automatic renewal, WooCommerce charges the saved payment method on its own each cycle. With manual renewal, it emails an invoice and waits for the customer to pay by hand. A subscription that has a valid saved token should almost always be on automatic renewal.
Is it safe to change renewal type with a script?
Yes, when the script only turns automatic renewal back on for active subscriptions that still carry a saved token, so it never forces automatic billing on a subscription that has no way to charge. Start in dry run mode to review the list before it writes.
Related field notes
Citations
On the problem:
- WooCommerce docs: automatic versus manual renewal for subscriptions. woocommerce.com/document/subscriptions/renewal-process
- WooCommerce docs: how the subscription gateway decides if automatic payments are supported. woocommerce.com/document/subscriptions/payment-gateways
- WooCommerce docs: the Stripe gateway stores a saved token for automatic renewals. woocommerce.com/document/stripe
On the solution:
- WooCommerce Subscriptions REST API: list subscriptions. woocommerce.github.io/subscriptions-rest-api-docs (list)
- WooCommerce Subscriptions REST API: update a subscription, including the manual renewal flag. woocommerce.github.io/subscriptions-rest-api-docs (update)
- WooCommerce REST API: authentication for the subscription endpoints. woocommerce.github.io/woocommerce-rest-api-docs (auth)
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 bring your renewals back?
If this switched a pile of subscriptions back to charging on their own, 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