Repair Payment lifecycle
A declined card leaves the WooCommerce order stuck on Pending
A buyer tries to pay, the card is declined, and they give up or try elsewhere. The order should move to Failed, but it stays on Pending. Over time the orders list fills with dead checkouts, stock stays reserved for payments that will never come, and your reports count carts that never converted. This guide finds the declined orders and moves them to Failed, cleanly and safely.
A declined card leaves the order on Pending instead of Failed. Read each pending Stripe order's PaymentIntent. If it is on requires_payment_method with a last_payment_error, the card was declined, so move the order to Failed, which releases the held stock. Orders still waiting on the buyer are left alone. The full code is below.
The problem in plain words
When someone reaches checkout, WooCommerce creates the order right away and holds stock for it. If the card is then declined, the order is supposed to move to Failed, which frees that stock and marks the checkout as unsuccessful. On some stores that final step does not run, so the order simply stays on Pending forever.
One stuck order is harmless. Hundreds are not. They reserve stock that real buyers cannot purchase, they bury genuine orders in a long Pending list, and they make your conversion numbers look worse than they are.
Why it happens
The WooCommerce Stripe plugin has reported cases where a declined payment does not move the order to Failed, leaving it on Pending. The order exists, the decline is recorded on Stripe, but the two never line up. The citations at the end link the threads.
Stripe records exactly why a payment did not go through. A declined card leaves the PaymentIntent on requires_payment_method with a last_payment_error that names the decline. That error is the clear signal that separates a real decline, which should fail the order, from a checkout still waiting on the buyer, which should not.
The fix, as a flow
We list pending Stripe orders that are older than a short window, read each order's PaymentIntent, and check for a decline. If the intent shows a payment error, we move the order to Failed, which releases the held stock. Orders whose intent is still waiting, with no error, are left alone so we never fail an active checkout.
Build it step by step
Detect a decline with a pure function
Keep the check in one function. A declined card leaves the PaymentIntent on requires_payment_method with a last_payment_error. If the error is missing, the checkout was likely just abandoned or is still open, so we do not treat it as a decline.
def is_declined(intent):
if intent.get("status") != "requires_payment_method":
return False
return bool(intent.get("last_payment_error"))
export function isDeclined(intent) {
if (intent.status !== "requires_payment_method") return false;
return Boolean(intent.last_payment_error);
}
List old pending Stripe orders
Ask the WooCommerce REST API for pending orders paid with Stripe. A short age filter keeps us from touching a checkout that is still in progress. Read the PaymentIntent ID from the order meta so we can look up its real state on Stripe.
def get_meta(order, key):
for m in order.get("meta_data", []):
if m.get("key") == key:
return m.get("value")
return None
def pending_stripe_orders(before_iso):
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "pending", "payment_method": "stripe",
"before": before_iso, "per_page": 50, "page": page},
auth=AUTH, timeout=30)
r.raise_for_status()
orders = r.json()
if not orders:
return
for order in orders:
yield order
page += 1
export function getMeta(order, key) {
const hit = (order.meta_data || []).find((m) => m.key === key);
return hit ? hit.value : null;
}
async function* pendingStripeOrders(beforeIso) {
let page = 1;
while (true) {
const orders = await woo(`/orders?status=pending&payment_method=stripe&before=${beforeIso}&per_page=50&page=${page}`);
if (!orders.length) return;
for (const order of orders) yield order;
page++;
}
}
Fail the declined orders
When the intent shows a decline, move the order to Failed and add a note that records the reason from Stripe. Failing the order is what returns the reserved stock to your catalog. Everything goes through the REST API, so High Performance Order Storage is handled.
def fail_order(order_id, intent):
error = intent.get("last_payment_error") or {}
reason = error.get("message") or error.get("code") or "card declined"
put_order(order_id, {"status": "failed"})
add_note(order_id, f"Stripe declined the payment: {reason}. Marked failed to release stock.")
async function failOrder(orderId, intent) {
const error = intent.last_payment_error || {};
const reason = error.message || error.code || "card declined";
await putOrder(orderId, { status: "failed" });
await addNote(orderId, `Stripe declined the payment: ${reason}. Marked failed to release stock.`);
}
Fail an order only when the PaymentIntent carries a payment error. A pending order with no error might be a checkout still in progress, a slow bank redirect, or a payment that will land in a moment. Always run with DRY_RUN=true first and read the list.
The full code
The complete script lists old pending Stripe orders, reads each PaymentIntent, and fails the ones that were declined. It skips anything without a clear payment error, so it never fails a live checkout.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Move WooCommerce orders left on Pending by a declined Stripe card to Failed.
Frees the held stock. Run on a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/woocommerce/declined-card-order-stuck-pending/
"""
import os
import time
import logging
from datetime import datetime, timezone, timedelta
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fail_declined")
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"])
MIN_AGE_HOURS = int(os.environ.get("MIN_AGE_HOURS", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def is_declined(intent):
if intent.get("status") != "requires_payment_method":
return False
return bool(intent.get("last_payment_error"))
def get_meta(order, key):
for m in order.get("meta_data", []):
if m.get("key") == key:
return m.get("value")
return None
def pending_stripe_orders(before_iso):
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "pending", "payment_method": "stripe",
"before": before_iso, "per_page": 50, "page": page},
auth=AUTH, timeout=30)
r.raise_for_status()
orders = r.json()
if not orders:
return
for order in orders:
yield order
page += 1
def put_order(order_id, body):
requests.put(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", json=body, auth=AUTH, timeout=30).raise_for_status()
def add_note(order_id, note):
requests.post(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": note}, auth=AUTH, timeout=30).raise_for_status()
def run():
before = (datetime.now(timezone.utc) - timedelta(hours=MIN_AGE_HOURS)).isoformat()
failed = 0
for order in pending_stripe_orders(before):
intent_id = get_meta(order, "_stripe_intent_id")
if not intent_id:
continue
intent = stripe.PaymentIntent.retrieve(intent_id)
if not is_declined(intent):
continue
error = intent.get("last_payment_error") or {}
reason = error.get("message") or error.get("code") or "card declined"
log.info("Order %s: declined (%s). %s", order["id"], reason, "would fail" if DRY_RUN else "failing")
if not DRY_RUN:
put_order(order["id"], {"status": "failed"})
add_note(order["id"], f"Stripe declined the payment: {reason}. Marked failed to release stock.")
failed += 1
log.info("Done. %d order(s) %s.", failed, "to fail" if DRY_RUN else "failed")
if __name__ == "__main__":
run()
/**
* Move WooCommerce orders left on Pending by a declined Stripe card to Failed.
* Frees the held stock. Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/woocommerce/declined-card-order-stuck-pending/
*/
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 MIN_AGE_HOURS = Number(process.env.MIN_AGE_HOURS || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function isDeclined(intent) {
if (intent.status !== "requires_payment_method") return false;
return Boolean(intent.last_payment_error);
}
export function getMeta(order, key) {
const hit = (order.meta_data || []).find((m) => m.key === key);
return hit ? hit.value : null;
}
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* pendingStripeOrders(beforeIso) {
let page = 1;
while (true) {
const orders = await woo(`/orders?status=pending&payment_method=stripe&before=${beforeIso}&per_page=50&page=${page}`);
if (!orders.length) return;
for (const order of orders) yield order;
page++;
}
}
async function run() {
const before = new Date(Date.now() - MIN_AGE_HOURS * 3600 * 1000).toISOString();
let failed = 0;
for await (const order of pendingStripeOrders(before)) {
const intentId = getMeta(order, "_stripe_intent_id");
if (!intentId) continue;
const intent = await stripe.paymentIntents.retrieve(intentId);
if (!isDeclined(intent)) continue;
const error = intent.last_payment_error || {};
const reason = error.message || error.code || "card declined";
console.log(`Order ${order.id}: declined (${reason}). ${DRY_RUN ? "would fail" : "failing"}`);
if (!DRY_RUN) {
await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "failed" }) });
await woo(`/orders/${order.id}/notes`, { method: "POST", body: JSON.stringify({ note: `Stripe declined the payment: ${reason}. Marked failed to release stock.` }) });
}
failed++;
}
console.log(`Done. ${failed} order(s) ${DRY_RUN ? "to fail" : "failed"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The decline check decides which orders get failed, so it is the piece to test. It is pure, so the test just passes in intent objects.
from fail_declined import is_declined
def test_declined_when_error_present():
intent = {"status": "requires_payment_method", "last_payment_error": {"code": "card_declined"}}
assert is_declined(intent) is True
def test_not_declined_without_error():
assert is_declined({"status": "requires_payment_method", "last_payment_error": None}) is False
def test_not_declined_when_waiting_on_3ds():
assert is_declined({"status": "requires_action", "last_payment_error": None}) is False
def test_not_declined_when_succeeded():
assert is_declined({"status": "succeeded"}) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isDeclined } from "./fail-declined.js";
test("declined when error present", () => {
assert.equal(isDeclined({ status: "requires_payment_method", last_payment_error: { code: "card_declined" } }), true);
});
test("not declined without error", () => {
assert.equal(isDeclined({ status: "requires_payment_method", last_payment_error: null }), false);
});
test("not declined when waiting on 3ds", () => {
assert.equal(isDeclined({ status: "requires_action", last_payment_error: null }), false);
});
test("not declined when succeeded", () => {
assert.equal(isDeclined({ status: "succeeded" }), false);
});
Case studies
The store drowning in dead checkouts
A busy shop had thousands of Pending orders, most of them declined cards that never failed. Staff could not tell real orders needing attention from dead ones, and fulfillment slowed down.
The script read each PaymentIntent, failed the declined orders, and cut the Pending list down to genuine, actionable orders. The team could see the real work again.
The last unit locked by a decline
A popular item showed out of stock because a declined checkout still held the final unit on a Pending order. A real buyer could not complete the purchase.
Once the declined order was failed, the unit returned to the catalog and sold within the hour to a buyer whose card actually worked.
After this runs on a schedule, a declined card no longer leaves a ghost order behind. Your Pending list holds only live checkouts, stock reflects what is truly available, and your conversion reports stop counting carts that never had a chance to pay.
FAQ
Why does a declined card leave a WooCommerce order on Pending?
When a card is declined at checkout, WooCommerce has already created the order, but the path that should move it to Failed does not always run. The order stays on Pending, holds stock, and clutters the orders list even though no payment will ever come.
How do I tell a declined order from one still waiting on the buyer?
Read the Stripe PaymentIntent. A declined card leaves the intent on requires_payment_method with a last_payment_error set. An intent still waiting on 3D Secure sits on requires_action with no error. The error is what marks a real decline.
Does marking the order Failed release the stock?
Yes. Moving an order to Failed returns the reserved stock to your catalog, so the units are available for buyers who can actually pay.
Related field notes
Citations
On the problem:
- WooCommerce Stripe plugin issue: declined payment leaves the order on Pending instead of Failed. github.com/woocommerce/woocommerce-gateway-stripe/issues/249
- WooCommerce Stripe plugin issue: order status not updated after a card decline. github.com/woocommerce/woocommerce-gateway-stripe/issues/392
- Stripe docs: declines and the last_payment_error on a PaymentIntent. docs.stripe.com/declines
On the solution:
- Stripe API: retrieve a PaymentIntent and read its status and last_payment_error. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: list orders with status and date filters. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce docs: order statuses and what Failed means for stock. woocommerce.com/document/managing-orders/order-statuses
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 clean up your Pending list?
If this cleared the dead checkouts and freed your held stock, you can buy me a coffee. It keeps these field notes free and growing.
Buy me a coffee on Ko-fi