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.

Python and Node.js Frees held stock Safe by default (dry run)
Brown wallet
Photo by Stephen Phillips - Hostreviews.co.uk on Unsplash
The short answer

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.

Card declined at checkout Order created stock reserved never failed Stuck Pending stock held List clutter wrong reports
The decline is real, but the order never reflects it, so it lingers and holds stock.

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.

The key insight

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.

Old pending orders paid with Stripe Read the intent from Stripe declined? payment error yes Mark Failed release stock
Only orders whose payment truly declined are failed. Active checkouts are never touched.

Build it step by step

1

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.

detect.py
def is_declined(intent):
    if intent.get("status") != "requires_payment_method":
        return False
    return bool(intent.get("last_payment_error"))
detect.js
export function isDeclined(intent) {
  if (intent.status !== "requires_payment_method") return false;
  return Boolean(intent.last_payment_error);
}
2

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.

step2.py
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
step2.js
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++;
  }
}
3

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.

fail.py
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.")
fail.js
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.`);
}
Only fail on a real error

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.

fail_declined.py
"""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()
fail-declined.js
/**
 * 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.

test_is_declined.py
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
is-declined.test.js
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

Long Pending list

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.

Held stock

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.

What good looks like

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:

  1. WooCommerce Stripe plugin issue: declined payment leaves the order on Pending instead of Failed. github.com/woocommerce/woocommerce-gateway-stripe/issues/249
  2. WooCommerce Stripe plugin issue: order status not updated after a card decline. github.com/woocommerce/woocommerce-gateway-stripe/issues/392
  3. Stripe docs: declines and the last_payment_error on a PaymentIntent. docs.stripe.com/declines

On the solution:

  1. Stripe API: retrieve a PaymentIntent and read its status and last_payment_error. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce REST API: list orders with status and date filters. woocommerce.github.io/woocommerce-rest-api-docs
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes