Reconciler Bulk subscription operations
Bulk cancel WooCommerce subscriptions without leaving Stripe still billing
You select forty subscriptions in the WooCommerce admin, choose Cancel from the bulk action menu, and the list refreshes with every row showing Cancelled. Job done, you think. Then a customer writes in three weeks later asking why Stripe just charged their card for a subscription they cancelled. The WooCommerce side moved. The Stripe side did not. Here is why a bulk cancel can leave the two systems disagreeing, and a small script that cancels a batch on both sides and tells you exactly what it did.
Cancelling a subscription in the WooCommerce admin changes the WooCommerce record, but it does not reliably cancel the linked Stripe subscription for every row in a large batch, especially when a request times out or the site loses the Stripe subscription id along the way. Run a small Python or Node.js script that takes a list of WooCommerce subscription IDs, reads the Stripe subscription id saved on each one, cancels it on Stripe, then sets the WooCommerce subscription to cancelled, skipping anything already cancelled on either side. Full code, tests, and a dry run guard are below.
The problem in plain words
A WooCommerce subscription is really two records pointing at each other. WooCommerce stores the subscription, its status, and its next payment date. Stripe stores its own subscription object, which is the thing that actually fires the renewal charge on schedule. The two are linked by an id, usually saved on the WooCommerce subscription as post meta such as _stripe_subscription_id.
When you cancel one subscription by hand in the admin, WooCommerce Subscriptions calls the payment gateway to cancel the Stripe side too, and that mostly works. A bulk action on dozens or hundreds of rows is a different story. It runs many gateway calls in one request, and if any of them time out, get rate limited by Stripe, or hit a subscription whose Stripe id was never saved, WooCommerce still marks the row Cancelled in its own table while Stripe quietly keeps the subscription active and keeps billing it.
Why it happens
WooCommerce Subscriptions documents that cancelling a subscription is meant to cancel it with the payment gateway as well, but a few everyday conditions break that link during a bulk action:
- The bulk request runs dozens of gateway API calls inside one PHP request, and the web server or a proxy times out before every call finishes, so the tail end of the batch never reaches Stripe at all.
- A subscription was created, migrated, or edited in a way that lost its saved Stripe subscription id, so there is nothing for the cancel call to target even though WooCommerce still marks the row cancelled.
- Stripe returns a rate limit or a temporary error on one call in the batch, WooCommerce logs it and moves on to the next row, and nobody notices the one failure buried in a log file.
- A subscription was already cancelled directly in the Stripe dashboard, so a later bulk cancel in WooCommerce hits a Stripe subscription that no longer exists and the call errors out silently.
None of this shows up in the WooCommerce admin. Every row says Cancelled. The only way to know for sure is to ask Stripe directly whether each subscription is actually cancelled there too.
A bulk action is not one operation, it is many small operations wearing one button. Treat a batch cancel as a list of independent jobs, each with its own outcome, and check the outcome of every one against Stripe rather than trusting that the WooCommerce list screen tells the whole story.
The fix, as a flow
Instead of relying on the admin's bulk action, we give the script a list of WooCommerce subscription IDs to cancel. For each one, it reads the linked Stripe subscription id, checks the live status on both sides, and only calls cancel where a system is not already cancelled. Subscriptions with no Stripe id on file are reported as orphans instead of being skipped quietly.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair with read and write access to orders, since WooCommerce Subscriptions exposes subscriptions through the same orders endpoints under the wc/v3 namespace. 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="4821,4822,4830,4901"
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="4821,4822,4830,4901"
export DRY_RUN="true" // start safe, change to false to write
Load the WooCommerce subscription and its Stripe id
Read the subscription through the REST API, then pull the Stripe subscription id from its meta, usually saved as _stripe_subscription_id. Some older or migrated subscriptions only carry it on transaction_id if that value starts with sub_, so we check both places before giving up.
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
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;
}
Decide, with one pure function
Keep the decision in its own function that takes the WooCommerce subscription and the Stripe subscription, which may be missing. It never guesses. If there is no Stripe id at all, it reports an orphan for manual review instead of quietly skipping. Otherwise it looks at each system separately and only asks to cancel the ones that are not already cancelled there.
WOO_CANCELLED_STATUSES = {"cancelled"}
STRIPE_CANCELLED_STATUSES = {"canceled", "incomplete_expired"}
def decide(woo_subscription, stripe_subscription):
if woo_subscription is None:
return ("orphan", "woocommerce subscription not found")
woo_status = woo_subscription["status"]
woo_done = woo_status in WOO_CANCELLED_STATUSES
if stripe_subscription is None:
if woo_done:
return ("orphan", "no Stripe subscription id on file, cannot confirm Stripe side")
return ("orphan", "no Stripe subscription id on file, cancel Stripe by hand")
stripe_done = stripe_subscription["status"] in STRIPE_CANCELLED_STATUSES
if woo_done and stripe_done:
return ("skip", "already cancelled on both sides")
if not woo_done and not stripe_done:
return ("cancel_both", "active on both sides")
if not stripe_done:
return ("cancel_stripe_only", "woo cancelled, stripe still active")
return ("cancel_woo_only", "stripe cancelled, woo still active")
const WOO_CANCELLED_STATUSES = new Set(["cancelled"]);
const STRIPE_CANCELLED_STATUSES = new Set(["canceled", "incomplete_expired"]);
export function decide(wooSubscription, stripeSubscription) {
if (!wooSubscription) return ["orphan", "woocommerce subscription not found"];
const wooDone = WOO_CANCELLED_STATUSES.has(wooSubscription.status);
if (!stripeSubscription) {
return wooDone
? ["orphan", "no Stripe subscription id on file, cannot confirm Stripe side"]
: ["orphan", "no Stripe subscription id on file, cancel Stripe by hand"];
}
const stripeDone = STRIPE_CANCELLED_STATUSES.has(stripeSubscription.status);
if (wooDone && stripeDone) return ["skip", "already cancelled on both sides"];
if (!wooDone && !stripeDone) return ["cancel_both", "active on both sides"];
if (!stripeDone) return ["cancel_stripe_only", "woo cancelled, stripe still active"];
return ["cancel_woo_only", "stripe cancelled, woo still active"];
}
Cancel on the side that needs it
When the action calls for it, cancel the Stripe subscription immediately (no need to wait for the current period to end, since the customer already asked to cancel), set the WooCommerce subscription status to cancelled, and add a note that names which side the script had to fix. That note is what makes a later audit easy.
def cancel_on_stripe(stripe_sub_id):
stripe.Subscription.cancel(stripe_sub_id)
def cancel_on_woo(subscription_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{subscription_id}",
json={"status": "cancelled"}, auth=AUTH, timeout=30,
).raise_for_status()
def add_note(subscription_id, text):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{subscription_id}/notes",
json={"note": text}, auth=AUTH, timeout=30,
).raise_for_status()
def apply_action(action, subscription_id, stripe_sub_id):
if action == "cancel_both":
cancel_on_stripe(stripe_sub_id)
cancel_on_woo(subscription_id)
add_note(subscription_id, "Bulk cancel sync: cancelled on Stripe and WooCommerce.")
elif action == "cancel_stripe_only":
cancel_on_stripe(stripe_sub_id)
add_note(subscription_id, "Bulk cancel sync: WooCommerce was already cancelled, "
"Stripe subscription cancelled to match.")
elif action == "cancel_woo_only":
cancel_on_woo(subscription_id)
add_note(subscription_id, "Bulk cancel sync: Stripe was already cancelled, "
"WooCommerce status corrected to match.")
async function cancelOnStripe(stripeSubId) {
await stripe.subscriptions.cancel(stripeSubId);
}
async function cancelOnWoo(subscriptionId) {
await woo(`/orders/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "cancelled" }),
});
}
async function addNote(subscriptionId, text) {
await woo(`/orders/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({ note: text }),
});
}
async function applyAction(action, subscriptionId, stripeSubId) {
if (action === "cancel_both") {
await cancelOnStripe(stripeSubId);
await cancelOnWoo(subscriptionId);
await addNote(subscriptionId, "Bulk cancel sync: cancelled on Stripe and WooCommerce.");
} else if (action === "cancel_stripe_only") {
await cancelOnStripe(stripeSubId);
await addNote(subscriptionId, "Bulk cancel sync: WooCommerce was already cancelled, " +
"Stripe subscription cancelled to match.");
} else if (action === "cancel_woo_only") {
await cancelOnWoo(subscriptionId);
await addNote(subscriptionId, "Bulk cancel sync: Stripe was already cancelled, " +
"WooCommerce status corrected to match.");
}
}
Wire it together with a dry run guard
The loop reads the batch of subscription IDs from the environment, runs each one through decide, and reports orphans and mismatches separately from the ones it actually changes. On the first run, leave DRY_RUN on so you can read the full plan, including every orphan, before anything is cancelled for real.
Always start with DRY_RUN=true. Cancelling a subscription is not reversible from the script's point of view, so you want to see the full list of what would be cancelled on each side, and every orphan it could not confirm, before you let it write anything.
The full code
Here is the complete script in one file for each language. It reads the batch from the environment, logs what it does per subscription, respects the dry run flag, and is safe to run again on the same list because anything already cancelled on both sides is left alone.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Cancel a batch of WooCommerce subscriptions on both WooCommerce and Stripe.
Give it a list of WooCommerce subscription IDs. It reads the linked Stripe
subscription id from meta, checks the live status on both systems, and only
cancels the side that is not already cancelled. Subscriptions with no Stripe
id on file are reported as orphans instead of being skipped silently.
Safe to run again and again. Run with DRY_RUN=true first.
"""
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_cancel_sync")
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_CANCELLED_STATUSES = {"cancelled"}
STRIPE_CANCELLED_STATUSES = {"canceled", "incomplete_expired"}
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 decide(woo_subscription, stripe_subscription):
if woo_subscription is None:
return ("orphan", "woocommerce subscription not found")
woo_status = woo_subscription["status"]
woo_done = woo_status in WOO_CANCELLED_STATUSES
if stripe_subscription is None:
if woo_done:
return ("orphan", "no Stripe subscription id on file, cannot confirm Stripe side")
return ("orphan", "no Stripe subscription id on file, cancel Stripe by hand")
stripe_done = stripe_subscription["status"] in STRIPE_CANCELLED_STATUSES
if woo_done and stripe_done:
return ("skip", "already cancelled on both sides")
if not woo_done and not stripe_done:
return ("cancel_both", "active on both sides")
if not stripe_done:
return ("cancel_stripe_only", "woo cancelled, stripe still active")
return ("cancel_woo_only", "stripe cancelled, woo still active")
def get_woo_subscription(subscription_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{subscription_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
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 cancel_on_stripe(stripe_sub_id):
stripe.Subscription.cancel(stripe_sub_id)
def cancel_on_woo(subscription_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{subscription_id}",
json={"status": "cancelled"}, auth=AUTH, timeout=30,
).raise_for_status()
def add_note(subscription_id, text):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{subscription_id}/notes",
json={"note": text}, auth=AUTH, timeout=30,
).raise_for_status()
def apply_action(action, subscription_id, stripe_sub_id):
if action == "cancel_both":
cancel_on_stripe(stripe_sub_id)
cancel_on_woo(subscription_id)
add_note(subscription_id, "Bulk cancel sync: cancelled on Stripe and WooCommerce.")
elif action == "cancel_stripe_only":
cancel_on_stripe(stripe_sub_id)
add_note(subscription_id, "Bulk cancel sync: WooCommerce was already cancelled, "
"Stripe subscription cancelled to match.")
elif action == "cancel_woo_only":
cancel_on_woo(subscription_id)
add_note(subscription_id, "Bulk cancel sync: Stripe was already cancelled, "
"WooCommerce status corrected to match.")
def run():
fixed = 0
orphans = 0
for subscription_id in SUBSCRIPTION_IDS:
woo_subscription = get_woo_subscription(subscription_id)
stripe_sub_id = stripe_sub_id_of(woo_subscription) if woo_subscription else None
stripe_subscription = get_stripe_subscription(stripe_sub_id)
action, reason = decide(woo_subscription, stripe_subscription)
if action == "orphan":
log.warning("Subscription %s: orphan. %s", subscription_id, reason)
orphans += 1
continue
if action == "skip":
continue
log.info("Subscription %s: %s. %s", subscription_id, reason,
"would fix" if DRY_RUN else "fixing")
if not DRY_RUN:
apply_action(action, subscription_id, stripe_sub_id)
fixed += 1
log.info("Done. %d subscription(s) %s, %d orphan(s) need manual review.",
fixed, "to fix" if DRY_RUN else "fixed", orphans)
if __name__ == "__main__":
run()
/**
* Cancel a batch of WooCommerce subscriptions on both WooCommerce and Stripe.
*
* Give it a list of WooCommerce subscription IDs. It reads the linked Stripe
* subscription id from meta, checks the live status on both systems, and only
* cancels the side that is not already cancelled. Subscriptions with no Stripe
* id on file are reported as orphans instead of being skipped silently.
* Safe to run again and again. Run with DRY_RUN=true first.
*/
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 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_CANCELLED_STATUSES = new Set(["cancelled"]);
const STRIPE_CANCELLED_STATUSES = new Set(["canceled", "incomplete_expired"]);
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;
}
export function decide(wooSubscription, stripeSubscription) {
if (!wooSubscription) return ["orphan", "woocommerce subscription not found"];
const wooDone = WOO_CANCELLED_STATUSES.has(wooSubscription.status);
if (!stripeSubscription) {
return wooDone
? ["orphan", "no Stripe subscription id on file, cannot confirm Stripe side"]
: ["orphan", "no Stripe subscription id on file, cancel Stripe by hand"];
}
const stripeDone = STRIPE_CANCELLED_STATUSES.has(stripeSubscription.status);
if (wooDone && stripeDone) return ["skip", "already cancelled on both sides"];
if (!wooDone && !stripeDone) return ["cancel_both", "active on both sides"];
if (!stripeDone) return ["cancel_stripe_only", "woo cancelled, stripe still active"];
return ["cancel_woo_only", "stripe cancelled, woo still active"];
}
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 getStripeSubscription(stripeSubId) {
if (!stripeSubId) return null;
try {
return await stripe.subscriptions.retrieve(stripeSubId);
} catch {
return null;
}
}
async function cancelOnStripe(stripeSubId) {
await stripe.subscriptions.cancel(stripeSubId);
}
async function cancelOnWoo(subscriptionId) {
await woo(`/orders/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "cancelled" }),
});
}
async function addNote(subscriptionId, text) {
await woo(`/orders/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({ note: text }),
});
}
async function applyAction(action, subscriptionId, stripeSubId) {
if (action === "cancel_both") {
await cancelOnStripe(stripeSubId);
await cancelOnWoo(subscriptionId);
await addNote(subscriptionId, "Bulk cancel sync: cancelled on Stripe and WooCommerce.");
} else if (action === "cancel_stripe_only") {
await cancelOnStripe(stripeSubId);
await addNote(subscriptionId, "Bulk cancel sync: WooCommerce was already cancelled, " +
"Stripe subscription cancelled to match.");
} else if (action === "cancel_woo_only") {
await cancelOnWoo(subscriptionId);
await addNote(subscriptionId, "Bulk cancel sync: Stripe was already cancelled, " +
"WooCommerce status corrected to match.");
}
}
export async function run() {
let fixed = 0;
let orphans = 0;
for (const subscriptionId of SUBSCRIPTION_IDS) {
const wooSubscription = await woo(`/orders/${subscriptionId}`);
const stripeSubId = wooSubscription ? stripeSubIdOf(wooSubscription) : null;
const stripeSubscription = await getStripeSubscription(stripeSubId);
const [action, reason] = decide(wooSubscription, stripeSubscription);
if (action === "orphan") {
console.warn(`Subscription ${subscriptionId}: orphan. ${reason}`);
orphans++;
continue;
}
if (action === "skip") continue;
console.log(`Subscription ${subscriptionId}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
if (!DRY_RUN) await applyAction(action, subscriptionId, stripeSubId);
fixed++;
}
console.log(`Done. ${fixed} subscription(s) ${DRY_RUN ? "to fix" : "fixed"}, ${orphans} orphan(s) need manual review.`);
}
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 most worth testing, since it decides which system gets a cancel call for every subscription in the batch. Because decide is pure, the tests need no network and no live Stripe key. They just feed in plain objects and check the action.
from bulk_cancel_sync import decide
def woo_sub(status="active"):
return {"id": 1, "status": status}
def stripe_sub(status="active"):
return {"id": "sub_1", "status": status}
def test_cancel_both_when_active_on_both_sides():
assert decide(woo_sub("active"), stripe_sub("active"))[0] == "cancel_both"
def test_skip_when_already_cancelled_on_both_sides():
assert decide(woo_sub("cancelled"), stripe_sub("canceled"))[0] == "skip"
def test_cancel_stripe_only_when_woo_already_cancelled():
action, _ = decide(woo_sub("cancelled"), stripe_sub("active"))
assert action == "cancel_stripe_only"
def test_cancel_woo_only_when_stripe_already_cancelled():
action, _ = decide(woo_sub("active"), stripe_sub("canceled"))
assert action == "cancel_woo_only"
def test_orphan_when_stripe_subscription_missing():
assert decide(woo_sub("active"), None)[0] == "orphan"
def test_orphan_when_woo_subscription_missing():
assert decide(None, stripe_sub("active"))[0] == "orphan"
def test_incomplete_expired_counts_as_cancelled_on_stripe():
action, _ = decide(woo_sub("active"), stripe_sub("incomplete_expired"))
assert action == "cancel_woo_only"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./bulk-cancel-sync.js";
const wooSub = (status = "active") => ({ id: 1, status });
const stripeSub = (status = "active") => ({ id: "sub_1", status });
test("cancel both when active on both sides", () => {
assert.equal(decide(wooSub("active"), stripeSub("active"))[0], "cancel_both");
});
test("skip when already cancelled on both sides", () => {
assert.equal(decide(wooSub("cancelled"), stripeSub("canceled"))[0], "skip");
});
test("cancel stripe only when woo already cancelled", () => {
assert.equal(decide(wooSub("cancelled"), stripeSub("active"))[0], "cancel_stripe_only");
});
test("cancel woo only when stripe already cancelled", () => {
assert.equal(decide(wooSub("active"), stripeSub("canceled"))[0], "cancel_woo_only");
});
test("orphan when stripe subscription missing", () => {
assert.equal(decide(wooSub("active"), null)[0], "orphan");
});
test("orphan when woo subscription missing", () => {
assert.equal(decide(null, stripeSub("active"))[0], "orphan");
});
test("incomplete_expired counts as cancelled on stripe", () => {
assert.equal(decide(wooSub("active"), stripeSub("incomplete_expired"))[0], "cancel_woo_only");
});
Case studies
The cleanup that stopped halfway through
A store closed down a discontinued plan and bulk cancelled 120 subscriptions from the admin in one go. The request ran long, the load balancer cut the connection at around ninety seconds, and the last eighteen rows never got their Stripe cancel call even though the admin list showed all 120 as Cancelled.
Running the script in dry run against the full list of 120 IDs surfaced exactly those eighteen as cancel_stripe_only. A second run with DRY_RUN=false closed every one of them out on Stripe and left a note explaining why.
The subscription cancelled in the wrong place
A support agent cancelled a handful of subscriptions directly in the Stripe dashboard to stop a billing dispute quickly, without going through WooCommerce. WooCommerce kept showing those subscriptions as Active for weeks, and the store's own reports still counted them as recurring revenue.
Feeding those subscription IDs into the script found each one as cancel_woo_only, since Stripe was already cancelled. WooCommerce was corrected to match in one pass, with no further Stripe calls needed.
After a bulk cancel, both systems agree, every subscription that could not be confirmed is on a short orphan list instead of hidden inside a log file, and a customer never sees another charge for something they already cancelled. Run the same script again on the same batch and it does nothing, because everything it already fixed is now genuinely in sync.
FAQ
Why is Stripe still billing a subscription I already cancelled in WooCommerce?
WooCommerce Subscriptions and Stripe are two separate records connected by a saved subscription id. The bulk action in the WooCommerce admin updates the order status but does not always call Stripe to cancel the matching subscription, especially when the action is applied to a large batch or the request times out partway through. Stripe then keeps renewing on schedule even though the store shows the subscription as cancelled.
Is it safe to cancel a batch of subscriptions with a script?
Yes, when the script reads the true state from both systems first and only cancels a subscription that is not already cancelled in that system. It skips anything already in sync and reports subscriptions it cannot match so a person can look at them by hand. Start in dry run mode to review the full plan before it writes anything.
What happens to a subscription that has no Stripe id on file?
The script flags it as an orphan instead of guessing. A missing Stripe subscription id usually means the link was never saved or was cleared by an earlier migration, and cancelling blind in that case risks missing the real Stripe subscription entirely. It is safer to list these for manual review than to skip them silently.
Related field notes
Citations
On the problem:
- WooCommerce Subscriptions docs: cancelling a subscription and how it should also cancel the payment gateway subscription. woocommerce.com/document/subscriptions/store-manager-guide
- WooCommerce docs: bulk actions on the Subscriptions admin list table. woocommerce.com/document/subscriptions/store-manager-guide
- Stripe docs: subscription statuses, including canceled and incomplete_expired. docs.stripe.com/billing/subscriptions/overview
On the solution:
- Stripe API: cancel a subscription immediately. docs.stripe.com/api/subscriptions/cancel
- Stripe API: retrieve a subscription by id to read its live status. docs.stripe.com/api/subscriptions/retrieve
- WooCommerce REST API: update an order or subscription status and add an order note. 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 a phantom charge?
If this saved a customer from being billed on a subscription they already cancelled, 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