Repair Bulk subscription operations
Bulk pause WooCommerce subscriptions and Stripe billing together
A support queue asks you to pause a batch of subscriptions, maybe fifty customers going on a payment holiday, maybe a whole plan getting retired for a quarter. You bulk edit them to On hold in WooCommerce and move on. A week later Stripe has quietly billed every one of them anyway, because pausing a subscription in WooCommerce and pausing it in Stripe are two different actions, and only one of them actually stops the charge.
Setting a WooCommerce Subscription to On hold only changes the order and post status in your store. If the subscription is billed through a Stripe Subscription object, Stripe keeps invoicing on its own schedule because nothing told it to stop. Run a small Python or Node.js script over a list of subscription IDs that, for each one, sets WooCommerce to on-hold and calls Stripe's pause_collection with behavior: "void", skipping anything already paused, cancelled, or missing a Stripe subscription id. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce Subscriptions has its own idea of "paused." When you bulk edit a group of subscriptions to On hold, the plugin updates the subscription's status and normally tells Action Scheduler to stop queuing the next renewal order. That part works fine and is entirely local to your store.
The trouble is the store is not always the thing collecting the money. With WooPayments and several Stripe setups, the actual billing is a Stripe Subscription object living on Stripe's side, ticking through its own billing cycle. Stripe does not watch your WordPress database. Unless your code calls the Stripe API to pause that subscription too, Stripe generates the next invoice and charges the card exactly on schedule, whatever WooCommerce says.
Do this one subscription at a time and someone might notice the mismatch. Do it in bulk across dozens or hundreds of subscriptions, and the gap turns into a wave of refund requests a few days later, right when nobody is looking at that batch anymore.
Why it happens
WooCommerce Subscriptions documents pausing as a store side change to the subscription status and its scheduled actions. Whether that also reaches the payment processor depends entirely on the gateway's integration code, and bulk table actions are the least likely path to trigger any extra API calls:
- The bulk "Change status to on-hold" action in the Subscriptions admin table updates many rows at once through a shared handler that was written for the local status change, not for calling out to a payment gateway per row.
- Some gateway integrations do hook subscription status changes to pause billing on save, but that hook is easy to miss during a bulk action, a REST API update, or a direct database change, all of which can skip the normal WordPress action hooks a single edit would fire.
- Stripe's own subscription lifecycle is independent by design. A Stripe Subscription keeps generating invoices on its billing_cycle_anchor until you explicitly call
pause_collectionor cancel it, since Stripe has no idea your store considers the customer "on hold." - Teams often pause in whichever admin screen they have open, WooCommerce or the Stripe Dashboard, and assume the other system will follow along. Neither one pushes state to the other automatically.
Pausing a subscription is really two separate pauses that happen to describe the same customer. WooCommerce pauses the store side, Action Scheduler, renewal orders, emails. Stripe pauses the money side, invoice generation and charges. A bulk pause script has to do both, for every subscription in the batch, and skip the ones that are not in a state where pausing makes sense.
The fix, as a flow
Instead of trusting the WooCommerce bulk action alone, we run a small script over a plain list of subscription IDs. For each one, we read the WooCommerce Subscription and the matching Stripe Subscription, decide whether it needs pausing, and if so we set WooCommerce to on-hold and call Stripe's pause_collection in the same pass. Anything already paused, cancelled, or with no Stripe subscription on file is left alone and reported, not guessed at.
Build it step by step
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. Keep every value in environment variables, never in the file.
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_IDS="1201,1202,1203"
export DRY_RUN="true" # start safe, change to false to write
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_IDS="1201,1202,1203"
export DRY_RUN="true" // start safe, change to false to write
Load the WooCommerce subscription
Subscriptions are exposed on the same REST API as orders, since WooCommerce Subscriptions models a subscription as a special order type. Reading it by ID works whether the store keeps orders in the posts table or in High Performance Order Storage (HPOS), because the REST API hides that detail.
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(sub_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
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();
}
async function getSubscription(subId) {
return woo(`/subscriptions/${subId}`);
}
Find the matching Stripe subscription
Read the Stripe subscription id from the WooCommerce subscription's meta, under the key _stripe_subscription_id (the same field the gateway saves it in), falling back to transaction_id when it looks like a subscription id. If that id is missing, the pair cannot be reconciled here and needs a person to look, so we treat it as its own case rather than guessing.
import stripe
def stripe_sub_id_of(subscription):
"""The saved Stripe Subscription id, from meta _stripe_subscription_id or transaction_id."""
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_subscription_id" and meta.get("value"):
return meta["value"]
tid = subscription.get("transaction_id")
return tid if tid and tid.startswith("sub_") else None
def get_stripe_subscription(stripe_sub_id):
if not stripe_sub_id:
return None
try:
return stripe.Subscription.retrieve(stripe_sub_id)
except stripe.error.InvalidRequestError:
return None
export function stripeSubIdOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_subscription_id" && meta.value) return meta.value;
}
const tid = subscription.transaction_id;
return tid && tid.startsWith("sub_") ? tid : null;
}
async function getStripeSubscription(stripeSubId) {
if (!stripeSubId) return null;
try {
return await stripe.subscriptions.retrieve(stripeSubId);
} catch {
return null;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the WooCommerce subscription and the Stripe subscription and returns an action. It skips anything already on hold, cancelled, or ended on either side, flags a subscription whose Stripe id cannot be found so a person can look, and only returns pause when both sides are genuinely active and safe to pause together.
WOO_ENDED_STATUSES = {"on-hold", "cancelled", "expired", "pending-cancel"}
STRIPE_ENDED_STATUSES = {"canceled", "incomplete_expired", "paused"}
def decide(subscription, stripe_sub):
if subscription["status"] in WOO_ENDED_STATUSES:
return ("skip", "WooCommerce subscription is not active")
if stripe_sub is None:
return ("orphan", "no Stripe subscription id on file")
if stripe_sub["status"] in STRIPE_ENDED_STATUSES or stripe_sub.get("pause_collection"):
return ("skip", "Stripe subscription already paused or ended")
return ("pause", "active in WooCommerce and billing in Stripe")
const WOO_ENDED_STATUSES = new Set(["on-hold", "cancelled", "expired", "pending-cancel"]);
const STRIPE_ENDED_STATUSES = new Set(["canceled", "incomplete_expired", "paused"]);
export function decide(subscription, stripeSub) {
if (WOO_ENDED_STATUSES.has(subscription.status)) {
return ["skip", "WooCommerce subscription is not active"];
}
if (!stripeSub) return ["orphan", "no Stripe subscription id on file"];
if (STRIPE_ENDED_STATUSES.has(stripeSub.status) || stripeSub.pause_collection) {
return ["skip", "Stripe subscription already paused or ended"];
}
return ["pause", "active in WooCommerce and billing in Stripe"];
}
Pause both sides together
When the action is pause, set the WooCommerce subscription to on-hold and add a note, then call Stripe with pause_collection: {"behavior": "void"}, which stops new invoices from being created without cancelling the subscription. Using void means any invoice that manages to get created while paused is voided rather than left open, which keeps the customer's balance clean while paused.
def pause_both(subscription_id, stripe_sub_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"status": "on-hold"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Bulk paused. Stripe subscription {stripe_sub_id} set to "
f"pause_collection (void) so it stops billing while on hold."},
auth=AUTH, timeout=30,
).raise_for_status()
stripe.Subscription.modify(stripe_sub_id, pause_collection={"behavior": "void"})
async function pauseBoth(subscriptionId, stripeSubId) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "on-hold" }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Bulk paused. Stripe subscription ${stripeSubId} set to ` +
`pause_collection (void) so it stops billing while on hold.`,
}),
});
await stripe.subscriptions.update(stripeSubId, { pause_collection: { behavior: "void" } });
}
Wire it together with a dry run guard
The loop reads the subscription ID list from the environment, walks each one through the same decision, and only writes when DRY_RUN is off. On the first run, leave DRY_RUN on so the script only reports what it would pause. Read the output, confirm the list matches the ticket, then switch it off.
Always start with DRY_RUN=true. A bulk pause touches many real subscriptions and their billing at once, so you want to see the exact list of what will change before anything writes. Once the report looks right, turn it off.
The full code
Here is the complete bulk pause script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only ever pauses a subscription that is active on both sides.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Pause a batch of WooCommerce subscriptions and their Stripe billing together.
Give it a list of subscription IDs. 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("bulk_pause")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
SUBSCRIPTION_IDS = [s.strip() for s in os.environ.get("SUBSCRIPTION_IDS", "").split(",") if s.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
WOO_ENDED_STATUSES = {"on-hold", "cancelled", "expired", "pending-cancel"}
STRIPE_ENDED_STATUSES = {"canceled", "incomplete_expired", "paused"}
def get_subscription(sub_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def stripe_sub_id_of(subscription):
for meta in subscription.get("meta_data") or []:
if meta.get("key") == "_stripe_subscription_id" and meta.get("value"):
return meta["value"]
tid = subscription.get("transaction_id")
return tid if tid and tid.startswith("sub_") else None
def get_stripe_subscription(stripe_sub_id):
if not stripe_sub_id:
return None
try:
return stripe.Subscription.retrieve(stripe_sub_id)
except stripe.error.InvalidRequestError:
return None
def decide(subscription, stripe_sub):
if subscription["status"] in WOO_ENDED_STATUSES:
return ("skip", "WooCommerce subscription is not active")
if stripe_sub is None:
return ("orphan", "no Stripe subscription id on file")
if stripe_sub["status"] in STRIPE_ENDED_STATUSES or stripe_sub.get("pause_collection"):
return ("skip", "Stripe subscription already paused or ended")
return ("pause", "active in WooCommerce and billing in Stripe")
def pause_both(subscription_id, stripe_sub_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"status": "on-hold"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Bulk paused. Stripe subscription {stripe_sub_id} set to "
f"pause_collection (void) so it stops billing while on hold."},
auth=AUTH, timeout=30,
).raise_for_status()
stripe.Subscription.modify(stripe_sub_id, pause_collection={"behavior": "void"})
def run():
paused = 0
for sub_id in SUBSCRIPTION_IDS:
subscription = get_subscription(sub_id)
if subscription is None:
log.warning("Subscription %s not found in WooCommerce", sub_id)
continue
stripe_sub_id = stripe_sub_id_of(subscription)
stripe_sub = get_stripe_subscription(stripe_sub_id)
action, reason = decide(subscription, stripe_sub)
if action == "orphan":
log.warning("Subscription %s: %s", sub_id, reason)
continue
if action == "skip":
continue
log.info("Subscription %s: %s. %s", sub_id, reason, "would pause" if DRY_RUN else "pausing")
if not DRY_RUN:
pause_both(sub_id, stripe_sub_id)
paused += 1
log.info("Done. %d subscription(s) %s.", paused, "to pause" if DRY_RUN else "paused")
if __name__ == "__main__":
run()
/**
* Pause a batch of WooCommerce subscriptions and their Stripe billing together.
* Give it a list of subscription IDs. Safe to run again and again.
*/
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
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 SUBSCRIPTION_IDS = (process.env.SUBSCRIPTION_IDS || "")
.split(",").map((s) => s.trim()).filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const WOO_ENDED_STATUSES = new Set(["on-hold", "cancelled", "expired", "pending-cancel"]);
const STRIPE_ENDED_STATUSES = new Set(["canceled", "incomplete_expired", "paused"]);
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 stripeSubIdOf(subscription) {
for (const meta of subscription.meta_data || []) {
if (meta.key === "_stripe_subscription_id" && meta.value) return meta.value;
}
const tid = subscription.transaction_id;
return tid && tid.startsWith("sub_") ? tid : null;
}
async function getStripeSubscription(stripeSubId) {
if (!stripeSubId) return null;
try {
return await stripe.subscriptions.retrieve(stripeSubId);
} catch {
return null;
}
}
function decide(subscription, stripeSub) {
if (WOO_ENDED_STATUSES.has(subscription.status)) {
return ["skip", "WooCommerce subscription is not active"];
}
if (!stripeSub) return ["orphan", "no Stripe subscription id on file"];
if (STRIPE_ENDED_STATUSES.has(stripeSub.status) || stripeSub.pause_collection) {
return ["skip", "Stripe subscription already paused or ended"];
}
return ["pause", "active in WooCommerce and billing in Stripe"];
}
async function pauseBoth(subscriptionId, stripeSubId) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "on-hold" }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Bulk paused. Stripe subscription ${stripeSubId} set to ` +
`pause_collection (void) so it stops billing while on hold.`,
}),
});
await stripe.subscriptions.update(stripeSubId, { pause_collection: { behavior: "void" } });
}
async function run() {
let paused = 0;
for (const subId of SUBSCRIPTION_IDS) {
const subscription = await woo(`/subscriptions/${subId}`);
if (!subscription) {
console.warn(`Subscription ${subId} not found in WooCommerce`);
continue;
}
const stripeSubId = stripeSubIdOf(subscription);
const stripeSub = await getStripeSubscription(stripeSubId);
const [action, reason] = decide(subscription, stripeSub);
if (action === "orphan") { console.warn(`Subscription ${subId}: ${reason}`); continue; }
if (action === "skip") continue;
console.log(`Subscription ${subId}: ${reason}. ${DRY_RUN ? "would pause" : "pausing"}`);
if (!DRY_RUN) await pauseBoth(subId, stripeSubId);
paused++;
}
console.log(`Done. ${paused} subscription(s) ${DRY_RUN ? "to pause" : "paused"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The decision rule is the part most worth testing, because it decides which real subscriptions get paused on both sides. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from bulk_pause import decide
def stripe_sub(**over):
base = {"status": "active", "pause_collection": None}
base.update(over)
return base
def test_pause_when_both_active():
subscription = {"status": "active"}
assert decide(subscription, stripe_sub())[0] == "pause"
def test_skip_when_woo_already_on_hold():
subscription = {"status": "on-hold"}
assert decide(subscription, stripe_sub())[0] == "skip"
def test_skip_when_stripe_already_paused():
subscription = {"status": "active"}
assert decide(subscription, stripe_sub(pause_collection={"behavior": "void"}))[0] == "skip"
def test_skip_when_stripe_canceled():
subscription = {"status": "active"}
assert decide(subscription, stripe_sub(status="canceled"))[0] == "skip"
def test_orphan_when_no_stripe_subscription():
subscription = {"status": "active"}
assert decide(subscription, None)[0] == "orphan"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./bulk-pause.js";
const stripeSub = (over = {}) => ({ status: "active", pause_collection: null, ...over });
test("pause when both active", () => {
assert.equal(decide({ status: "active" }, stripeSub())[0], "pause");
});
test("skip when woo already on hold", () => {
assert.equal(decide({ status: "on-hold" }, stripeSub())[0], "skip");
});
test("skip when stripe already paused", () => {
assert.equal(decide({ status: "active" }, stripeSub({ pause_collection: { behavior: "void" } }))[0], "skip");
});
test("skip when stripe canceled", () => {
assert.equal(decide({ status: "active" }, stripeSub({ status: "canceled" }))[0], "skip");
});
test("orphan when no stripe subscription", () => {
assert.equal(decide({ status: "active" }, null)[0], "orphan");
});
Case studies
The plan that went quiet for the summer
A store paused a whole seasonal plan, ninety subscriptions, using the bulk action in the WooCommerce Subscriptions table. Every row switched to On hold within seconds and support closed the ticket.
Stripe kept billing all ninety on their usual monthly date, since nothing had told Stripe to stop. The bulk pause script found every one still active in Stripe, paused collection on each, and the next billing date came and went with zero charges, exactly as planned.
The list that had already been touched
A support agent was handed a list of sixty subscription IDs to pause, but a handful had already been cancelled by the customer and two had no Stripe subscription id on file from an old migration.
Run in dry run first, the script reported fifty six as safe to pause, skipped the cancelled ones with a reason, and flagged the two orphans for a person to check instead of guessing what to do with them.
After this runs, a bulk pause means what it says: the customer stops being charged and the store stops expecting a renewal, on the same day, for every subscription in the batch. Resuming later is the mirror of this script, clearing pause_collection in Stripe and setting the WooCommerce status back to active.
FAQ
Why does a subscription keep billing in Stripe after I pause it in WooCommerce?
WooCommerce Subscriptions changing a subscription to On hold only updates the order and post status in your store. It does not call Stripe. If the subscription is billed through a Stripe Subscription (common with WooPayments and some Stripe integrations), Stripe keeps invoicing on its own schedule until something tells it to stop.
Is it safe to bulk pause subscriptions with a script?
Yes, when the script only pauses subscriptions that are active on both sides and skips ones that are already paused, cancelled, or missing a Stripe subscription id. Start in dry run mode so you can review the exact list before anything is written.
Will pausing in Stripe cancel the subscription?
No, not if you use pause_collection with behavior set to void or keep_as_draft. That stops Stripe from creating and charging new invoices while the subscription itself stays intact, so resuming later is just clearing the pause.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: subscription statuses, including what On hold changes and what it does not touch. woocommerce.com/document/subscriptions
- WooCommerce docs: Stripe subscriptions and how billing is delegated to a Stripe Subscription object. woocommerce.com/document/stripe
- Stripe docs: the subscription lifecycle and how a subscription keeps billing until it is explicitly paused or cancelled. docs.stripe.com/billing/subscriptions/overview
On the solution:
- Stripe docs: pause payment collection on a subscription with pause_collection and the void and keep_as_draft behaviors. docs.stripe.com/billing/subscriptions/pause
- Stripe API: update a subscription, including the pause_collection parameter. docs.stripe.com/api/subscriptions/update
- WooCommerce REST API: subscriptions endpoint, for reading and updating subscription status and notes. 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 save your bulk pause?
If this saved you a wave of refund requests after a batch pause, 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