Reconciler Subscription lifecycle
Changing the card flips the WooCommerce subscription to Pending
A customer updated the card on their subscription because the bank asked for a fresh 3D Secure check. They completed the verification, Stripe shows the new card saved and confirmed, and the subscription still says Pending. No renewal has run since, and the customer has quietly stopped being billed while thinking everything is fine. This guide finds those subscriptions and sets them back to active once the card change is proven to have worked.
WooCommerce Subscriptions moves a subscription to Pending while a card change waits on a 3D Secure SetupIntent, then moves it back to active once it hears the result. If that confirmation is lost, the subscription is stuck on Pending even though the SetupIntent already succeeded. Read the SetupIntent id saved on the subscription (from meta _stripe_intent_id or transaction_id), confirm on Stripe that it is succeeded and its payment method matches the subscription's current card, then set the status back to active. Full code, tests, and a dry run guard are below.
The problem in plain words
Cards expire, get replaced, or trigger a bank's fraud check when a customer updates them. WooCommerce Subscriptions handles this by creating a Stripe SetupIntent, which is Stripe's way of verifying a card without charging it. Some banks require the customer to complete a 3D Secure step for that verification, and while WooCommerce waits for the result, it puts the subscription on Pending as a safety measure. That part is by design.
The bug shows up after the customer finishes the verification. Stripe marks the SetupIntent as succeeded and attaches the new card to the customer. The store is supposed to hear about that success and move the subscription back to active. When that final step is missed, because the redirect back to the store failed, the webhook did not arrive, or the handler errored quietly, the subscription is left exactly where the wait step put it: Pending, with a verified card sitting unused and no renewal running.
Why it happens
WooCommerce Subscriptions and the Stripe gateway coordinate this hand-off between two systems, and a few common gaps break it:
- The customer completes the 3D Secure redirect, but they close the tab or lose connection before the browser lands back on the store's return URL, so the client side confirmation step never runs.
- The
setup_intent.succeededwebhook is blocked, times out, or errors on the server, the same way a payment webhook can go missing. - A caching or security layer serves a stale page for the return URL instead of letting the request reach WooCommerce, so the "resume the subscription" code path never executes even though the customer's browser did everything right.
- The store was down or in maintenance mode for the few seconds the confirmation arrived, and nothing retried it afterward.
None of these are the customer's fault, and Stripe's own record of the SetupIntent is unaffected by any of them. The card is verified on Stripe's side the moment 3D Secure finishes. Only the WooCommerce side is waiting on a message that already happened and will not arrive again on its own.
Stripe's SetupIntent status is the source of truth for whether the card change worked. If a SetupIntent tied to a Pending subscription shows succeeded and its payment method matches the card now on file for that subscription, the card change is done and the Pending status is just stale. A reconciler is a safety net that checks that proof on a schedule and resumes the subscriptions the confirmation step missed.
The fix, as a flow
We do not touch the checkout or the card-change flow itself. We add a job that runs every few minutes, lists subscriptions that are Pending, reads the SetupIntent id saved on each one, and asks Stripe whether that SetupIntent succeeded with a payment method that matches the card the subscription is currently pointing at. When both are true, we set the subscription back to active and leave a note, the same way the missed confirmation would have.
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. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API, and grant it access to the Subscriptions endpoints too. 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 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 DRY_RUN="true" // start safe, change to false to write
List Pending subscriptions and read the saved SetupIntent id
The Subscriptions REST API lists subscriptions by status. WooCommerce Subscriptions saves the Stripe intent id used for the card change either in the subscription meta under _stripe_intent_id or, on some versions, as the transaction_id on the subscription itself. We check both places, since either can hold it depending on the plugin version.
def pending_subscriptions():
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "pending", "per_page": 50, "page": page},
auth=AUTH, timeout=30)
r.raise_for_status()
subs = r.json()
if not subs:
return
for sub in subs:
yield sub
page += 1
def get_meta(sub, key):
for m in sub.get("meta_data", []):
if m.get("key") == key:
return m.get("value")
return None
def intent_id_of(sub):
"""The saved Stripe SetupIntent id, from meta _stripe_intent_id or transaction_id."""
meta_id = get_meta(sub, "_stripe_intent_id")
if meta_id:
return meta_id
tid = sub.get("transaction_id")
return tid if tid and tid.startswith("seti_") else None
export async function* pendingSubscriptions() {
let page = 1;
while (true) {
const subs = await woo(`/subscriptions?status=pending&per_page=50&page=${page}`);
if (!subs.length) return;
for (const sub of subs) yield sub;
page++;
}
}
export function getMeta(sub, key) {
const hit = (sub.meta_data || []).find((m) => m.key === key);
return hit ? hit.value : null;
}
export function intentIdOf(sub) {
// The saved Stripe SetupIntent id, from meta _stripe_intent_id or transaction_id.
const metaId = getMeta(sub, "_stripe_intent_id");
if (metaId) return metaId;
const tid = sub.transaction_id;
return tid && tid.startsWith("seti_") ? tid : null;
}
Read the current card on the subscription and the SetupIntent on Stripe
WooCommerce stores the current payment method token for a subscription in its meta, typically _stripe_source_id or _payment_method_token depending on the gateway version. Retrieve the SetupIntent from Stripe and compare its resulting payment method to that stored token, so we never resume a subscription onto a card it does not actually have on file.
def current_card_token(sub):
return get_meta(sub, "_stripe_source_id") or get_meta(sub, "_payment_method_token")
def get_setup_intent(intent_id):
if not intent_id:
return None
try:
return stripe.SetupIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
export function currentCardToken(sub) {
return getMeta(sub, "_stripe_source_id") || getMeta(sub, "_payment_method_token");
}
async function getSetupIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.setupIntents.retrieve(intentId);
} catch {
return null;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription status, the SetupIntent, and the subscription's current card token, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule stays narrow on purpose. If the subscription is not Pending, skip it. If there is no SetupIntent or it has not succeeded, skip it and leave the subscription waiting. If the payment method on the SetupIntent does not match the card stored on the subscription, skip it and flag the mismatch rather than guess. Only when both checks pass do we resume the subscription.
def decide(sub_status, intent, current_card_token):
if sub_status != "pending":
return ("skip", "subscription not pending")
if intent is None:
return ("wait", "no setup intent on file yet")
if intent.get("status") != "succeeded":
return ("wait", "setup intent has not succeeded")
intent_pm = intent.get("payment_method")
if not intent_pm or not current_card_token:
return ("mismatch", "missing payment method to compare")
if intent_pm != current_card_token:
return ("mismatch", "setup intent card does not match saved card")
return ("resume", "card change verified, safe to reactivate")
export function decide(subStatus, intent, currentCardToken) {
if (subStatus !== "pending") return ["skip", "subscription not pending"];
if (!intent) return ["wait", "no setup intent on file yet"];
if (intent.status !== "succeeded") return ["wait", "setup intent has not succeeded"];
const intentPm = intent.payment_method;
if (!intentPm || !currentCardToken) return ["mismatch", "missing payment method to compare"];
if (intentPm !== currentCardToken) return ["mismatch", "setup intent card does not match saved card"];
return ["resume", "card change verified, safe to reactivate"];
}
Resume the subscription the way the confirmation would have
When the action is resume, set the subscription status to active through the Subscriptions REST API. This does not change the billing schedule or create any charge, it only corrects the status that was waiting on a confirmation that never arrived. Then add a note so the shop manager can see why it happened and which SetupIntent proved it.
def resume(sub_id, intent_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"status": "active"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Card change verified on Stripe SetupIntent {intent_id}. "
f"The confirmation back to the store was missed, so this was "
f"set back to active by the reconciler."},
auth=AUTH, timeout=30,
).raise_for_status()
async function resume(subId, intentId) {
await woo(`/subscriptions/${subId}`, {
method: "PUT",
body: JSON.stringify({ status: "active" }),
});
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Card change verified on Stripe SetupIntent ${intentId}. ` +
`The confirmation back to the store was missed, so this was ` +
`set back to active by the reconciler.`,
}),
});
}
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 do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron every ten to fifteen minutes, since a 3D Secure check can take the customer a little while to complete.
Always start with DRY_RUN=true. This script resumes billing on a real subscription, 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 reconciler 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 is not Pending or whose card change is not proven.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Resume WooCommerce subscriptions left Pending after a verified card change.
Confirms the SetupIntent succeeded and its card matches before resuming.
Run on a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/woocommerce/card-change-flips-sub-to-pending/
"""
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("resume_pending_after_card_change")
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"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def pending_subscriptions():
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "pending", "per_page": 50, "page": page},
auth=AUTH, timeout=30)
r.raise_for_status()
subs = r.json()
if not subs:
return
for sub in subs:
yield sub
page += 1
def get_meta(sub, key):
for m in sub.get("meta_data", []):
if m.get("key") == key:
return m.get("value")
return None
def intent_id_of(sub):
"""The saved Stripe SetupIntent id, from meta _stripe_intent_id or transaction_id."""
meta_id = get_meta(sub, "_stripe_intent_id")
if meta_id:
return meta_id
tid = sub.get("transaction_id")
return tid if tid and tid.startswith("seti_") else None
def current_card_token(sub):
return get_meta(sub, "_stripe_source_id") or get_meta(sub, "_payment_method_token")
def get_setup_intent(intent_id):
if not intent_id:
return None
try:
return stripe.SetupIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def decide(sub_status, intent, current_card_token):
if sub_status != "pending":
return ("skip", "subscription not pending")
if intent is None:
return ("wait", "no setup intent on file yet")
if intent.get("status") != "succeeded":
return ("wait", "setup intent has not succeeded")
intent_pm = intent.get("payment_method")
if not intent_pm or not current_card_token:
return ("mismatch", "missing payment method to compare")
if intent_pm != current_card_token:
return ("mismatch", "setup intent card does not match saved card")
return ("resume", "card change verified, safe to reactivate")
def resume(sub_id, intent_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}",
json={"status": "active"},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}/notes",
json={"note": f"Card change verified on Stripe SetupIntent {intent_id}. "
f"The confirmation back to the store was missed, so this was "
f"set back to active by the reconciler."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
resumed = 0
for sub in pending_subscriptions():
intent_id = intent_id_of(sub)
intent = get_setup_intent(intent_id)
action, reason = decide(sub["status"], intent, current_card_token(sub))
if action in ("skip", "wait"):
continue
if action == "mismatch":
log.warning("Subscription %s: %s", sub["id"], reason)
continue
log.info("Subscription %s: %s. %s", sub["id"], reason, "would resume" if DRY_RUN else "resuming")
if not DRY_RUN:
resume(sub["id"], intent_id)
resumed += 1
log.info("Done. %d subscription(s) %s.", resumed, "to resume" if DRY_RUN else "resumed")
if __name__ == "__main__":
run()
/**
* Resume WooCommerce subscriptions left Pending after a verified card change.
* Confirms the SetupIntent succeeded and its card matches before resuming.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/woocommerce/card-change-flips-sub-to-pending/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
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();
}
export async function* pendingSubscriptions() {
let page = 1;
while (true) {
const subs = await woo(`/subscriptions?status=pending&per_page=50&page=${page}`);
if (!subs.length) return;
for (const sub of subs) yield sub;
page++;
}
}
export function getMeta(sub, key) {
const hit = (sub.meta_data || []).find((m) => m.key === key);
return hit ? hit.value : null;
}
export function intentIdOf(sub) {
// The saved Stripe SetupIntent id, from meta _stripe_intent_id or transaction_id.
const metaId = getMeta(sub, "_stripe_intent_id");
if (metaId) return metaId;
const tid = sub.transaction_id;
return tid && tid.startsWith("seti_") ? tid : null;
}
export function currentCardToken(sub) {
return getMeta(sub, "_stripe_source_id") || getMeta(sub, "_payment_method_token");
}
async function getSetupIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.setupIntents.retrieve(intentId);
} catch {
return null;
}
}
export function decide(subStatus, intent, currentCardToken) {
if (subStatus !== "pending") return ["skip", "subscription not pending"];
if (!intent) return ["wait", "no setup intent on file yet"];
if (intent.status !== "succeeded") return ["wait", "setup intent has not succeeded"];
const intentPm = intent.payment_method;
if (!intentPm || !currentCardToken) return ["mismatch", "missing payment method to compare"];
if (intentPm !== currentCardToken) return ["mismatch", "setup intent card does not match saved card"];
return ["resume", "card change verified, safe to reactivate"];
}
async function resume(subId, intentId) {
await woo(`/subscriptions/${subId}`, { method: "PUT", body: JSON.stringify({ status: "active" }) });
await woo(`/subscriptions/${subId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Card change verified on Stripe SetupIntent ${intentId}. ` +
`The confirmation back to the store was missed, so this was ` +
`set back to active by the reconciler.`,
}),
});
}
export async function run() {
let resumed = 0;
for await (const sub of pendingSubscriptions()) {
const intentId = intentIdOf(sub);
const intent = await getSetupIntent(intentId);
const [action, reason] = decide(sub.status, intent, currentCardToken(sub));
if (action === "skip" || action === "wait") continue;
if (action === "mismatch") { console.warn(`Subscription ${sub.id}: ${reason}`); continue; }
console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would resume" : "resuming"}`);
if (!DRY_RUN) await resume(sub.id, intentId);
resumed++;
}
console.log(`Done. ${resumed} subscription(s) ${DRY_RUN ? "to resume" : "resumed"}.`);
}
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 which subscriptions get resumed. 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 resume_pending_after_card_change import decide
def intent(**over):
base = {"status": "succeeded", "payment_method": "pm_new"}
base.update(over)
return base
def test_resume_when_succeeded_and_card_matches():
assert decide("pending", intent(), "pm_new")[0] == "resume"
def test_wait_when_no_intent_yet():
assert decide("pending", None, "pm_new")[0] == "wait"
def test_wait_when_intent_not_succeeded():
assert decide("pending", intent(status="requires_action"), "pm_new")[0] == "wait"
def test_mismatch_when_card_differs():
assert decide("pending", intent(), "pm_old")[0] == "mismatch"
def test_skip_when_not_pending():
assert decide("active", intent(), "pm_new")[0] == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./resume-pending-after-card-change.js";
const intent = (over = {}) => ({ status: "succeeded", payment_method: "pm_new", ...over });
test("resume when succeeded and card matches", () => {
assert.equal(decide("pending", intent(), "pm_new")[0], "resume");
});
test("wait when no intent yet", () => {
assert.equal(decide("pending", null, "pm_new")[0], "wait");
});
test("wait when intent not succeeded", () => {
assert.equal(decide("pending", intent({ status: "requires_action" }), "pm_new")[0], "wait");
});
test("mismatch when card differs", () => {
assert.equal(decide("pending", intent(), "pm_old")[0], "mismatch");
});
test("skip when not pending", () => {
assert.equal(decide("active", intent(), "pm_new")[0], "skip");
});
Case studies
The renewal that quietly stopped
A customer's bank forced a 3D Secure step on every new card, including the one they added to replace an expiring one. They completed the check on their phone, closed the browser tab out of habit, and never saw the store's confirmation page load. The subscription sat Pending for three weeks with no renewal and no error the customer noticed.
The reconciler found the Pending subscription, confirmed the SetupIntent had succeeded with the same card now on file, and resumed it. The next scheduled renewal ran on time.
The stale page that swallowed the confirmation
A store's page cache served a cached copy of the account page for the card-change return URL instead of letting the request hit WooCommerce fresh. Dozens of card changes completed successfully on Stripe, but none of the subscriptions moved off Pending.
Run in dry run first, the reconciler listed every affected subscription with the matching SetupIntent id. Once the caching rule was fixed, the team ran it for real and every one resumed cleanly.
After this runs on a schedule, a lost confirmation no longer means a silently paused subscription. A verified card change resumes billing within minutes on its own, and you find out about a genuine card problem from the mismatch warnings instead of from a customer asking why they were never charged.
FAQ
Why did changing my card put the WooCommerce subscription on Pending?
The new card needed a 3D Secure check, so WooCommerce Subscriptions moved the subscription to Pending while it waited for the customer to finish that verification. When the confirmation redirect or webhook that reports the result never reaches the store, the subscription is left on Pending even though the card was verified and saved, and renewals stop until someone notices.
How do I know the card change actually succeeded?
Read the SetupIntent id saved on the subscription, from meta _stripe_intent_id or from transaction_id, and check it on Stripe. If the SetupIntent status is succeeded and its payment method matches what is now saved on the subscription, the change went through cleanly and the Pending status is stale.
Is it safe to set the subscription back to active with a script?
Yes, when the script only acts on subscriptions that are Pending and confirms on Stripe that the saved SetupIntent succeeded with a payment method that matches the subscription. It skips anything still awaiting action or with a mismatched card, so an unresolved 3D Secure check is never overridden.
Related field notes
Citations
On the problem:
- WooCommerce docs: subscription statuses, including what Pending means and when it is used. woocommerce.com/document/subscriptions/statuses
- Stripe docs: how 3D Secure authentication works with SetupIntents and why a redirect can be interrupted. docs.stripe.com/payments/3d-secure
- WooCommerce Stripe plugin issue tracker: reports of subscriptions not resuming after a completed card update. github.com/woocommerce/woocommerce-gateway-stripe/issues
On the solution:
- Stripe API: retrieve a SetupIntent and read its status and payment method. docs.stripe.com/api/setup_intents/retrieve
- WooCommerce Subscriptions REST API: list and update subscriptions. woocommerce.github.io/subscriptions-rest-api-docs
- WooCommerce REST API: add a note to an order or subscription. 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 resume a stuck subscription?
If this brought a paused subscription back to active without you having to comb through Stripe by hand, 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