Repair Cancellation sync

Stripe keeps billing a WooCommerce subscription after it was cancelled

A customer cancels, or you cancel it for them, and WooCommerce shows the subscription as Cancelled. Everyone moves on. Then a month later the same card gets charged again, because Stripe never got the message. WooCommerce closed its side of the deal. Stripe kept running its own billing clock. Here is why that gap opens up and a small script that closes the Stripe side before the next renewal date.

Python and Node.js Runs on a schedule Safe by default (dry run)
A stack of books on a wooden table
Photo by Vision Magazin on Unsplash
The short answer

Cancelling a WooCommerce Subscription updates the subscription and the order in WordPress, but that is not the same call as telling Stripe to stop billing. If the request to cancel the linked Stripe Subscription is skipped, times out, or fails quietly, Stripe keeps its own billing cycle running and charges the saved card again on the next renewal date. Run a small Python or Node.js script on a schedule that reads the Stripe subscription id saved on each cancelled WooCommerce subscription and cancels it in Stripe when Stripe still shows it as active, trialing, or past_due. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions and Stripe both keep their own record of a subscription's state. WooCommerce stores a subscription status on the order, things like Active, On hold, and Cancelled. Stripe keeps a completely separate Subscription object with its own status, things like active, past_due, and canceled. Normally, cancelling in WooCommerce also fires a call to Stripe to cancel the matching Stripe Subscription, and the two stay in step.

When that second call does not go through, WooCommerce is telling you the story is over while Stripe is still turning the crank. The shop owner sees Cancelled and stops thinking about it. The customer sees a cancellation email and stops thinking about it. Weeks later Stripe's billing engine reaches the next renewal date, finds an active subscription with a saved card on file, and charges it, exactly as designed. Nobody did anything wrong on that day. The gap was opened much earlier, at the moment of cancellation.

Customer cancels from My Account WooCommerce marks subscription Cancelled cancel call lost Stripe subscription still status: active Renewal date arrives card charged again everyone stops looking
WooCommerce closes its side of the record. Stripe never gets the message, so its own billing clock keeps running.

Why it happens

WooCommerce documents the Stripe gateway's job as keeping both systems in step, but that link can break at the exact moment it matters most, cancellation. A few common reasons the Stripe side is left running:

This is a well known edge of the WooCommerce Stripe gateway's design: the plugin owns keeping the two systems in sync, but a single failed API call at cancellation time has no automatic retry. See the citations at the end for the exact pages.

The key insight

Stripe is the source of truth for whether a card will be charged again. If WooCommerce shows a subscription as Cancelled and Stripe still shows the linked Subscription as active, trialing, or past_due, Stripe is the one that will act, not WooCommerce. A repair script is a safety net that runs on a schedule, compares the two records, and closes the Stripe side whenever WooCommerce already considers the story over.

The fix, as a flow

We do not change how a customer or shop owner cancels a subscription. We add a job that runs once a day, looks at WooCommerce Subscriptions that are cancelled, pending-cancel, or expired, and checks the matching Stripe Subscription. If Stripe still shows it as something that would be billed, we cancel it in Stripe and leave a note on the order explaining why, the same way the gateway would have done at cancellation time.

Scheduled job once a day List cancelled Woo subscriptions (last 7d) Read Stripe sub id from order meta Stripe still billable? yes no, already agrees Cancel in Stripe add order note
The script reads the truth from Stripe and only closes subscriptions that WooCommerce already considers cancelled. Everything else is left alone.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders and subscriptions. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true"   // start safe, change to false to write
2

List the WooCommerce subscriptions that were recently cancelled

Ask the WooCommerce REST API for subscriptions with a status of cancelled, pending-cancel, or expired that were modified in your lookback window. Page through all of them. This keeps the script cheap to run daily, since it only ever looks at subscriptions the shop already considers closed.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
CANCELLED_WOO_STATUSES = {"cancelled", "pending-cancel", "expired"}

def cancelled_woo_subscriptions():
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=7)}T00:00:00"
    statuses = ",".join(CANCELLED_WOO_STATUSES)
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": statuses, "modified_after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for subscription in batch:
            yield subscription
        page += 1
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const CANCELLED_WOO_STATUSES = new Set(["cancelled", "pending-cancel", "expired"]);

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* cancelledWooSubscriptions() {
  const after = new Date(Date.now() - 7 * 86400000).toISOString();
  const statuses = [...CANCELLED_WOO_STATUSES].join(",");
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=${statuses}&modified_after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const subscription of batch) yield subscription;
    page++;
  }
}
3

Read the saved Stripe subscription id and look it up

The WooCommerce Stripe gateway normally saves the Stripe Subscription id in order meta under _stripe_subscription_id. Some older setups only saved a value under _stripe_intent_id that is actually the subscription id, or put it in transaction_id. Check all three, since a missing id means the script has nothing to act on and that is worth logging on its own.

step3.py
import stripe

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"]
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            value = meta["value"]
            if value.startswith("sub_"):
                return value
    tid = subscription.get("transaction_id")
    return tid if tid and tid.startswith("sub_") else None

def get_stripe_subscription(sub_id):
    if not sub_id:
        return None
    try:
        return stripe.Subscription.retrieve(sub_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
function stripeSubIdOf(subscription) {
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_subscription_id" && meta.value) return meta.value;
  }
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && typeof meta.value === "string" && meta.value.startsWith("sub_")) {
      return meta.value;
    }
  }
  const tid = subscription.transaction_id;
  return tid && tid.startsWith("sub_") ? tid : null;
}

async function getStripeSubscription(subId) {
  if (!subId) return null;
  try {
    return await stripe.subscriptions.retrieve(subId);
  } catch {
    return null;
  }
}
4

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. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If WooCommerce does not consider it cancelled, leave it alone. If there is no Stripe subscription to check, flag it as an orphan. If Stripe still shows it as something that would be billed, cancel it. Otherwise the two sides already agree.

decide.py
CANCELLED_WOO_STATUSES = {"cancelled", "pending-cancel", "expired"}
STILL_BILLING_STRIPE_STATUSES = {"active", "trialing", "past_due", "unpaid"}

def decide(woo_subscription, stripe_subscription):
    if woo_subscription["status"] not in CANCELLED_WOO_STATUSES:
        return ("skip", "WooCommerce subscription is not cancelled")
    if stripe_subscription is None:
        return ("orphan", "no Stripe subscription id saved on this subscription")
    if stripe_subscription.get("status") in STILL_BILLING_STRIPE_STATUSES:
        return ("cancel", "Woo is cancelled but Stripe would still bill it")
    return ("ok", "Stripe already shows this subscription as over")
decide.js
const CANCELLED_WOO_STATUSES = new Set(["cancelled", "pending-cancel", "expired"]);
const STILL_BILLING_STRIPE_STATUSES = new Set(["active", "trialing", "past_due", "unpaid"]);

export function decide(wooSubscription, stripeSubscription) {
  if (!CANCELLED_WOO_STATUSES.has(wooSubscription.status)) {
    return ["skip", "WooCommerce subscription is not cancelled"];
  }
  if (!stripeSubscription) {
    return ["orphan", "no Stripe subscription id saved on this subscription"];
  }
  if (STILL_BILLING_STRIPE_STATUSES.has(stripeSubscription.status)) {
    return ["cancel", "Woo is cancelled but Stripe would still bill it"];
  }
  return ["ok", "Stripe already shows this subscription as over"];
}
5

Cancel it in Stripe and record why

When the action is cancel, call Stripe's cancel endpoint on the subscription, then add an order note so the shop manager can see it was repaired and why. This is the exact call the gateway should have made at cancellation time, run late but before the next renewal date does any damage.

apply.py
def cancel_in_stripe(woo_subscription, stripe_subscription):
    stripe.Subscription.cancel(stripe_subscription["id"])
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{woo_subscription['id']}/notes",
        json={"note": f"Stripe subscription {stripe_subscription['id']} was still {stripe_subscription['status']} "
                      f"after this subscription was cancelled in WooCommerce. Canceled it in Stripe so the "
                      f"customer is not billed again."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function cancelInStripe(wooSubscription, stripeSubscription) {
  await stripe.subscriptions.cancel(stripeSubscription.id);
  await woo(`/orders/${wooSubscription.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Stripe subscription ${stripeSubscription.id} was still ${stripeSubscription.status} ` +
            `after this subscription was cancelled in WooCommerce. Canceled it in Stripe so the ` +
            `customer is not billed again.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would cancel. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once a day, since renewal dates are usually weeks apart.

Run it safe

Always start with DRY_RUN=true. Canceling a Stripe subscription is a real, permanent action, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete repair script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches a subscription that Stripe and WooCommerce already agree on.

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

cancel_stripe_subscription.py
"""Stop Stripe from billing a WooCommerce subscription that was already cancelled.

Cancelling a WooCommerce Subscription only updates the order and the local
subscription post. It does not, by itself, guarantee the linked Stripe
Subscription object gets canceled too. If that second cancel call is skipped,
delayed, or lost, Stripe's billing cycle keeps running and the customer's card
is charged again on the next renewal date even though WooCommerce shows the
subscription as cancelled.

This walks recently cancelled WooCommerce subscriptions, reads the saved
Stripe subscription id from meta, and cancels the Stripe side for any
subscription Stripe still shows as active, trialing, or past_due. Read only
by default (DRY_RUN). Run on a schedule.
"""
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("cancel_stripe_subscription")

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"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# WooCommerce Subscriptions statuses that mean "the shop owner considers this closed."
CANCELLED_WOO_STATUSES = {"cancelled", "pending-cancel", "expired"}

# Stripe subscription statuses that mean Stripe will still try to bill it.
STILL_BILLING_STRIPE_STATUSES = {"active", "trialing", "past_due", "unpaid"}


def stripe_sub_id_of(subscription):
    """The saved Stripe Subscription id, from meta _stripe_subscription_id or
    falling back to the _stripe_intent_id prefix used by some gateway versions.
    """
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_subscription_id" and meta.get("value"):
            return meta["value"]
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            value = meta["value"]
            if value.startswith("sub_"):
                return value
    tid = subscription.get("transaction_id")
    return tid if tid and tid.startswith("sub_") else None


def decide(woo_subscription, stripe_subscription):
    """Pure decision: does the Stripe side need to be canceled?

    woo_subscription    -- the WooCommerce Subscriptions order-like dict for
                            the subscription (has "status" and "meta_data")
    stripe_subscription -- the Stripe Subscription dict (or None if there is
                            no id saved, or Stripe has no record of it)

    Returns a tuple of (action, reason). action is one of:
      "cancel"  -- Woo is cancelled but Stripe is still set to bill, cancel it
      "skip"    -- Woo subscription is not in a cancelled state, leave alone
      "ok"      -- Stripe already agrees the subscription is over
      "orphan"  -- no Stripe subscription id was ever saved, cannot act
    """
    if woo_subscription["status"] not in CANCELLED_WOO_STATUSES:
        return ("skip", "WooCommerce subscription is not cancelled")
    if stripe_subscription is None:
        return ("orphan", "no Stripe subscription id saved on this subscription")
    if stripe_subscription.get("status") in STILL_BILLING_STRIPE_STATUSES:
        return ("cancel", "Woo is cancelled but Stripe would still bill it")
    return ("ok", "Stripe already shows this subscription as over")


def get_stripe_subscription(sub_id):
    if not sub_id:
        return None
    try:
        return stripe.Subscription.retrieve(sub_id)
    except stripe.error.InvalidRequestError:
        return None


def cancelled_woo_subscriptions():
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    statuses = ",".join(CANCELLED_WOO_STATUSES)
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": statuses, "modified_after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for subscription in batch:
            yield subscription
        page += 1


def cancel_in_stripe(woo_subscription, stripe_subscription):
    stripe.Subscription.cancel(stripe_subscription["id"])
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{woo_subscription['id']}/notes",
        json={"note": f"Stripe subscription {stripe_subscription['id']} was still {stripe_subscription['status']} "
                      f"after this subscription was cancelled in WooCommerce. Canceled it in Stripe so the "
                      f"customer is not billed again."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for woo_subscription in cancelled_woo_subscriptions():
        sub_id = stripe_sub_id_of(woo_subscription)
        stripe_subscription = get_stripe_subscription(sub_id)
        action, reason = decide(woo_subscription, stripe_subscription)
        if action == "orphan":
            log.warning("Subscription %s has no saved Stripe subscription id", woo_subscription["id"])
            continue
        if action in ("skip", "ok"):
            continue
        log.info("Subscription %s: %s. %s", woo_subscription["id"], reason,
                  "would cancel" if DRY_RUN else "canceling")
        if not DRY_RUN:
            cancel_in_stripe(woo_subscription, stripe_subscription)
        fixed += 1
    log.info("Done. %d subscription(s) %s.", fixed, "to cancel in Stripe" if DRY_RUN else "canceled in Stripe")


if __name__ == "__main__":
    run()
cancel-stripe-subscription.js
/**
 * Stop Stripe from billing a WooCommerce subscription that was already cancelled.
 *
 * Cancelling a WooCommerce Subscription only updates the order and the local
 * subscription post. It does not, by itself, guarantee the linked Stripe
 * Subscription object gets canceled too. If that second cancel call is skipped,
 * delayed, or lost, Stripe's billing cycle keeps running and the customer's card
 * is charged again on the next renewal date even though WooCommerce shows the
 * subscription as cancelled.
 *
 * This walks recently cancelled WooCommerce subscriptions, reads the saved
 * Stripe subscription id from meta, and cancels the Stripe side for any
 * subscription Stripe still shows as active, trialing, or past_due. Read only
 * by default (DRY_RUN). Run on a schedule.
 */
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// WooCommerce Subscriptions statuses that mean "the shop owner considers this closed."
const CANCELLED_WOO_STATUSES = new Set(["cancelled", "pending-cancel", "expired"]);

// Stripe subscription statuses that mean Stripe will still try to bill it.
const STILL_BILLING_STRIPE_STATUSES = new Set(["active", "trialing", "past_due", "unpaid"]);

/**
 * The saved Stripe Subscription id, from meta _stripe_subscription_id or
 * falling back to the _stripe_intent_id prefix used by some gateway versions.
 */
export function stripeSubIdOf(subscription) {
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_subscription_id" && meta.value) return meta.value;
  }
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && typeof meta.value === "string" && meta.value.startsWith("sub_")) {
      return meta.value;
    }
  }
  const tid = subscription.transaction_id;
  return tid && tid.startsWith("sub_") ? tid : null;
}

/**
 * Pure decision: does the Stripe side need to be canceled?
 *
 * wooSubscription    - the WooCommerce Subscriptions order-like object (has
 *                       "status" and "meta_data")
 * stripeSubscription - the Stripe Subscription object (or null if there is
 *                       no id saved, or Stripe has no record of it)
 *
 * Returns [action, reason]. action is one of:
 *   "cancel" - Woo is cancelled but Stripe is still set to bill, cancel it
 *   "skip"   - Woo subscription is not in a cancelled state, leave alone
 *   "ok"     - Stripe already agrees the subscription is over
 *   "orphan" - no Stripe subscription id was ever saved, cannot act
 */
export function decide(wooSubscription, stripeSubscription) {
  if (!CANCELLED_WOO_STATUSES.has(wooSubscription.status)) {
    return ["skip", "WooCommerce subscription is not cancelled"];
  }
  if (!stripeSubscription) {
    return ["orphan", "no Stripe subscription id saved on this subscription"];
  }
  if (STILL_BILLING_STRIPE_STATUSES.has(stripeSubscription.status)) {
    return ["cancel", "Woo is cancelled but Stripe would still bill it"];
  }
  return ["ok", "Stripe already shows this subscription as over"];
}

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 getStripeSubscription(subId) {
  if (!subId) return null;
  try {
    return await stripe.subscriptions.retrieve(subId);
  } catch {
    return null;
  }
}

async function* cancelledWooSubscriptions() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  const statuses = [...CANCELLED_WOO_STATUSES].join(",");
  let page = 1;
  while (true) {
    const batch = await woo(
      `/subscriptions?status=${statuses}&modified_after=${after}&per_page=50&page=${page}`
    );
    if (!batch.length) return;
    for (const subscription of batch) yield subscription;
    page++;
  }
}

async function cancelInStripe(wooSubscription, stripeSubscription) {
  await stripe.subscriptions.cancel(stripeSubscription.id);
  await woo(`/orders/${wooSubscription.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Stripe subscription ${stripeSubscription.id} was still ${stripeSubscription.status} ` +
            `after this subscription was cancelled in WooCommerce. Canceled it in Stripe so the ` +
            `customer is not billed again.`,
    }),
  });
}

export async function run() {
  let fixed = 0;
  for await (const wooSubscription of cancelledWooSubscriptions()) {
    const subId = stripeSubIdOf(wooSubscription);
    const stripeSubscription = await getStripeSubscription(subId);
    const [action, reason] = decide(wooSubscription, stripeSubscription);
    if (action === "orphan") {
      console.warn(`Subscription ${wooSubscription.id} has no saved Stripe subscription id`);
      continue;
    }
    if (action === "skip" || action === "ok") continue;
    console.log(`Subscription ${wooSubscription.id}: ${reason}. ${DRY_RUN ? "would cancel" : "canceling"}`);
    if (!DRY_RUN) await cancelInStripe(wooSubscription, stripeSubscription);
    fixed++;
  }
  console.log(`Done. ${fixed} subscription(s) ${DRY_RUN ? "to cancel in Stripe" : "canceled in Stripe"}.`);
}

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, because it decides whether a real Stripe subscription gets canceled. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_stripebills_decide.py
from cancel_stripe_subscription import decide, stripe_sub_id_of


def woo_sub(**over):
    base = {"id": 501, "status": "cancelled", "meta_data": []}
    base.update(over)
    return base


def stripe_sub(**over):
    base = {"id": "sub_1", "status": "active"}
    base.update(over)
    return base


def test_cancel_when_woo_cancelled_and_stripe_still_active():
    assert decide(woo_sub(), stripe_sub())[0] == "cancel"


def test_cancel_when_stripe_past_due():
    assert decide(woo_sub(status="pending-cancel"), stripe_sub(status="past_due"))[0] == "cancel"


def test_ok_when_stripe_already_canceled():
    assert decide(woo_sub(), stripe_sub(status="canceled"))[0] == "ok"


def test_skip_when_woo_subscription_not_cancelled():
    assert decide(woo_sub(status="active"), stripe_sub())[0] == "skip"


def test_orphan_when_no_stripe_subscription():
    assert decide(woo_sub(), None)[0] == "orphan"


def test_stripe_sub_id_from_meta():
    sub = {"meta_data": [{"key": "_stripe_subscription_id", "value": "sub_123"}]}
    assert stripe_sub_id_of(sub) == "sub_123"


def test_stripe_sub_id_falls_back_to_intent_meta_prefix():
    sub = {"meta_data": [{"key": "_stripe_intent_id", "value": "sub_456"}]}
    assert stripe_sub_id_of(sub) == "sub_456"


def test_stripe_sub_id_falls_back_to_transaction_id():
    sub = {"meta_data": [], "transaction_id": "sub_789"}
    assert stripe_sub_id_of(sub) == "sub_789"


def test_stripe_sub_id_none_when_nothing_matches():
    sub = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": "ch_1"}
    assert stripe_sub_id_of(sub) is None
cancel-stripe-subscription.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, stripeSubIdOf } from "./cancel-stripe-subscription.js";

const wooSub = (over = {}) => ({ id: 501, status: "cancelled", meta_data: [], ...over });
const stripeSub = (over = {}) => ({ id: "sub_1", status: "active", ...over });

test("cancel when woo cancelled and stripe still active", () => {
  assert.equal(decide(wooSub(), stripeSub())[0], "cancel");
});

test("cancel when stripe past_due", () => {
  assert.equal(decide(wooSub({ status: "pending-cancel" }), stripeSub({ status: "past_due" }))[0], "cancel");
});

test("ok when stripe already canceled", () => {
  assert.equal(decide(wooSub(), stripeSub({ status: "canceled" }))[0], "ok");
});

test("skip when woo subscription not cancelled", () => {
  assert.equal(decide(wooSub({ status: "active" }), stripeSub())[0], "skip");
});

test("orphan when no stripe subscription", () => {
  assert.equal(decide(wooSub(), null)[0], "orphan");
});

test("stripeSubIdOf from meta", () => {
  assert.equal(
    stripeSubIdOf({ meta_data: [{ key: "_stripe_subscription_id", value: "sub_123" }] }),
    "sub_123"
  );
});

test("stripeSubIdOf falls back to intent meta prefix", () => {
  assert.equal(
    stripeSubIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "sub_456" }] }),
    "sub_456"
  );
});

test("stripeSubIdOf falls back to transaction_id", () => {
  assert.equal(stripeSubIdOf({ meta_data: [], transaction_id: "sub_789" }), "sub_789");
});

test("stripeSubIdOf null when nothing matches", () => {
  assert.equal(
    stripeSubIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "ch_1" }),
    null
  );
});

Case studies

Bulk import

The migration that carried old subscriptions along for the ride

A store moved to a new host and restored a database backup that included a batch of subscriptions a support agent had already cancelled by hand in WooCommerce weeks earlier, without ever calling Stripe's API. WooCommerce still showed them as Cancelled, but the Stripe Subscriptions behind them were untouched and very much active.

The repair script found forty two of them on its first scheduled run in dry run mode, canceled all forty two in Stripe once the list was confirmed, and stopped a wave of surprise renewal charges before the next billing date hit.

Rate limit

The refund day that also broke cancellations

During a busy end of month refund run, a store's own script was hammering the Stripe API and tripped a rate limit right as a customer cancelled their subscription. WooCommerce's cancel flow updated the order but its own call to Stripe failed silently on the rate limit and was never retried.

Running the daily repair script caught the one affected subscription the next morning, well before its next renewal date, and canceled it in Stripe with a clear note explaining what had happened.

What good looks like

After this runs on a schedule, a lost cancel call is no longer a surprise charge weeks later. The worst case becomes a delay of at most a day before the script closes the Stripe side. Keep it running even after you track down whatever caused a specific gap, since a single failed API call at cancellation time can happen again for a completely different reason.

FAQ

Why did Stripe charge my customer after I cancelled their WooCommerce subscription?

Cancelling a WooCommerce Subscription changes its status in WordPress, but that is a separate step from telling Stripe to stop billing the linked Stripe Subscription. If the cancel call to Stripe never runs or fails quietly, Stripe keeps its own billing cycle going and charges the card again on the next renewal date.

Is it safe to cancel a Stripe subscription with a script?

Yes, when the script only acts on subscriptions WooCommerce already shows as cancelled, pending-cancel, or expired, and Stripe still shows as active, trialing, past_due, or unpaid. It never changes a subscription that WooCommerce still considers live. Start in dry run mode to review the list before it writes.

How often should this repair script run?

Once a day is enough for most stores, since it only needs to catch a cancellation before the next renewal date, which is usually weeks away. Running it more often is safe too, since it skips anything already in agreement.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how a subscription's status in WooCommerce relates to the underlying payment gateway. woocommerce.com/document/subscriptions
  2. WooCommerce docs: Stripe order and subscription statuses and how the gateway keeps both sides updated. woocommerce.com/document/stripe
  3. Stripe docs: subscription statuses and what each one means for billing. docs.stripe.com/billing/subscriptions/overview

On the solution:

  1. Stripe API: cancel a subscription immediately or at period end. docs.stripe.com/api/subscriptions/cancel
  2. Stripe API: retrieve a subscription to read its current status before acting. docs.stripe.com/api/subscriptions/retrieve
  3. WooCommerce REST API: list subscriptions by 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.

Contact me on LinkedIn

Did this stop a surprise charge?

If this saved a customer from an unwanted renewal or saved you a refund and an angry email, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes