Reconciler Payment lifecycle

Clean up abandoned Stripe PaymentIntents cluttering WooCommerce

Every time a shopper opens checkout, Stripe can create a PaymentIntent. The ones who leave without paying leave those intents behind, showing as Incomplete in the Stripe dashboard, each with a matching pending order in WooCommerce. Over time they pile up, inflate your open-order count, and make your conversion look worse than it is. This guide cancels the ones that are truly abandoned, and leaves real payments alone.

Python and Node.js Runs on a schedule Never cancels a real payment
Grey shopping cart
Photo by Bruno Kelzer on Unsplash
The short answer

Abandoned checkouts leave PaymentIntents on requires_payment_method or requires_confirmation with no payment attempt and no charge. List those that are older than a threshold, confirm they never paid, then cancel the PaymentIntent and cancel the matching pending order to release stock. Declines and late successes are left for their own reconcilers. The full code is below.

The problem in plain words

A PaymentIntent is created early in checkout, often as soon as the payment step loads. If the shopper pays, it completes. If they wander off, it just sits there, never finished. Stripe labels these Incomplete, and each one usually has a pending order in WooCommerce holding its place.

A handful is normal. A busy store builds thousands. They clog the Stripe dashboard, they bury real pending orders that need attention, and they quietly drag down every conversion number that counts started checkouts.

Checkout opens intent created Shopper leaves never pays Incomplete intent pending order Pile up skewed numbers
Started but never finished checkouts leave incomplete intents and pending orders that add up.

Why it happens

Creating the PaymentIntent early is by design, so the payment form can render. The side effect is that every abandoned checkout leaves one behind. Stripe eventually cancels very old incomplete intents on its own, but that can take about a week, and it does not tidy up the WooCommerce side. The citations at the end link the lifecycle docs.

The key insight

An abandoned intent has a clear signature. It sits on requires_payment_method or requires_confirmation, it has no successful charge, and it carries no payment error, which is what separates it from a decline. Add an age threshold and you can safely cancel it and its pending order without ever touching a real payment.

The fix, as a flow

We list PaymentIntents from a recent window, keep the ones that are old and abandoned, and for each one cancel the intent on Stripe and cancel the matching pending order in WooCommerce. Anything that shows a decline, a late success, or is still recent is left for its own reconciler or for the shopper to finish.

List intents recent window abandoned and old? yes Cancel intent on Stripe Cancel order release stock
Cancel only the old, never-attempted intents and their pending orders. Real payments are never touched.

Build it step by step

1

Recognise an abandoned intent with a pure function

Keep the rule in one function. An intent is abandoned when it is waiting for a payment method or confirmation, has no payment error, and is older than your threshold. The missing error is what rules out declines, which have their own fix.

detect.py
ABANDONED = {"requires_payment_method", "requires_confirmation"}

def is_abandoned(intent, age_hours, threshold_hours):
    if intent.get("status") not in ABANDONED:
        return False
    if intent.get("last_payment_error"):
        return False
    return age_hours >= threshold_hours
detect.js
const ABANDONED = new Set(["requires_payment_method", "requires_confirmation"]);

export function isAbandoned(intent, ageHours, thresholdHours) {
  if (!ABANDONED.has(intent.status)) return false;
  if (intent.last_payment_error) return false;
  return ageHours >= thresholdHours;
}
2

List the intents and their age

Ask Stripe for PaymentIntents created in your window and compute each one's age. Keep only the ones with an order ID in their metadata, so we can find and cancel the matching order.

step2.py
import time, stripe

def intents_with_age(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
        if intent.metadata.get("order_id"):
            age_hours = (time.time() - intent["created"]) / 3600
            yield intent, age_hours
step2.js
export async function* intentsWithAge(stripe, lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
    if (intent.metadata.order_id) {
      const ageHours = (Date.now() / 1000 - intent.created) / 3600;
      yield { intent, ageHours };
    }
  }
}
3

Cancel the intent and the pending order

Cancel the PaymentIntent on Stripe with a clear reason, then cancel the matching WooCommerce order if it is still pending, which releases any held stock. Only touch an order that is still pending, so a paid order is never cancelled by mistake.

cancel.py
def cancel_abandoned(order_id, intent):
    stripe.PaymentIntent.cancel(intent["id"], cancellation_reason="abandoned")
    order = get_order(order_id)
    if order and order["status"] == "pending":
        put_order(order_id, {"status": "cancelled"})
        add_note(order_id, f"Checkout was abandoned. Cancelled the Stripe PaymentIntent "
                           f"{intent['id']} and the order to release stock.")
cancel.js
async function cancelAbandoned(orderId, intent) {
  await stripe.paymentIntents.cancel(intent.id, { cancellation_reason: "abandoned" });
  const order = await woo(`/orders/${orderId}`);
  if (order && order.status === "pending") {
    await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "cancelled" }) });
    await woo(`/orders/${orderId}/notes`, {
      method: "POST",
      body: JSON.stringify({ note: `Checkout was abandoned. Cancelled the Stripe PaymentIntent ${intent.id} and the order to release stock.` }),
    });
  }
}
Set the threshold generously

Use a threshold of several hours, not minutes. A shopper might step away mid-checkout and come back. Cancelling too soon would kill a payment they were about to finish. Always run with DRY_RUN=true first and read what it would cancel.

The full code

The complete reconciler lists recent PaymentIntents, cancels the ones that are old and abandoned, and cancels their pending orders. It never touches an intent with a charge, an error, or an order that is already paid, so it is safe to run on a schedule.

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

cancel_abandoned.py
"""Cancel abandoned Stripe PaymentIntents and their pending WooCommerce orders.
Never touches a real payment. Run on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/woocommerce/cancel-abandoned-payment-intents/
"""
import os
import time
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_abandoned")

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_HOURS = int(os.environ.get("LOOKBACK_HOURS", "168"))
THRESHOLD_HOURS = int(os.environ.get("THRESHOLD_HOURS", "12"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ABANDONED = {"requires_payment_method", "requires_confirmation"}


def is_abandoned(intent, age_hours, threshold_hours):
    if intent.get("status") not in ABANDONED:
        return False
    if intent.get("last_payment_error"):
        return False
    return age_hours >= threshold_hours


def intents_with_age(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
        if intent.metadata.get("order_id"):
            age_hours = (time.time() - intent["created"]) / 3600
            yield intent, age_hours


def get_order(order_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


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():
    cancelled = 0
    for intent, age_hours in intents_with_age(LOOKBACK_HOURS):
        if not is_abandoned(intent, age_hours, THRESHOLD_HOURS):
            continue
        order_id = intent.metadata["order_id"]
        log.info("Intent %s (order %s) abandoned. %s", intent.id, order_id, "would cancel" if DRY_RUN else "cancelling")
        if not DRY_RUN:
            stripe.PaymentIntent.cancel(intent["id"], cancellation_reason="abandoned")
            order = get_order(order_id)
            if order and order["status"] == "pending":
                put_order(order_id, {"status": "cancelled"})
                add_note(order_id, f"Checkout was abandoned. Cancelled the Stripe PaymentIntent "
                                   f"{intent['id']} and the order to release stock.")
        cancelled += 1
    log.info("Done. %d abandoned intent(s) %s.", cancelled, "to cancel" if DRY_RUN else "cancelled")


if __name__ == "__main__":
    run()
cancel-abandoned.js
/**
 * Cancel abandoned Stripe PaymentIntents and their pending WooCommerce orders.
 * Never touches a real payment. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/cancel-abandoned-payment-intents/
 */
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 LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 168);
const THRESHOLD_HOURS = Number(process.env.THRESHOLD_HOURS || 12);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ABANDONED = new Set(["requires_payment_method", "requires_confirmation"]);

export function isAbandoned(intent, ageHours, thresholdHours) {
  if (!ABANDONED.has(intent.status)) return false;
  if (intent.last_payment_error) return false;
  return ageHours >= thresholdHours;
}

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* intentsWithAge(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
    if (intent.metadata.order_id) {
      const ageHours = (Date.now() / 1000 - intent.created) / 3600;
      yield { intent, ageHours };
    }
  }
}

async function run() {
  let cancelled = 0;
  for await (const { intent, ageHours } of intentsWithAge(LOOKBACK_HOURS)) {
    if (!isAbandoned(intent, ageHours, THRESHOLD_HOURS)) continue;
    const orderId = intent.metadata.order_id;
    console.log(`Intent ${intent.id} (order ${orderId}) abandoned. ${DRY_RUN ? "would cancel" : "cancelling"}`);
    if (!DRY_RUN) {
      await stripe.paymentIntents.cancel(intent.id, { cancellation_reason: "abandoned" });
      const order = await woo(`/orders/${orderId}`);
      if (order && order.status === "pending") {
        await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "cancelled" }) });
        await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note: `Checkout was abandoned. Cancelled the Stripe PaymentIntent ${intent.id} and the order to release stock.` }) });
      }
    }
    cancelled++;
  }
  console.log(`Done. ${cancelled} abandoned intent(s) ${DRY_RUN ? "to cancel" : "cancelled"}.`);
}

run().catch((err) => { console.error(err); process.exit(1); });

Add a test

The rule that decides what counts as abandoned is the piece to test, since it protects real payments. It is pure, so the test passes in intent objects and ages.

test_is_abandoned.py
from cancel_abandoned import is_abandoned


def test_abandoned_when_old_and_no_error():
    assert is_abandoned({"status": "requires_payment_method"}, 24, 12) is True


def test_not_abandoned_when_recent():
    assert is_abandoned({"status": "requires_payment_method"}, 2, 12) is False


def test_not_abandoned_when_declined():
    intent = {"status": "requires_payment_method", "last_payment_error": {"code": "card_declined"}}
    assert is_abandoned(intent, 24, 12) is False


def test_not_abandoned_when_succeeded():
    assert is_abandoned({"status": "succeeded"}, 24, 12) is False
is-abandoned.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isAbandoned } from "./cancel-abandoned.js";

test("abandoned when old and no error", () => {
  assert.equal(isAbandoned({ status: "requires_payment_method" }, 24, 12), true);
});

test("not abandoned when recent", () => {
  assert.equal(isAbandoned({ status: "requires_payment_method" }, 2, 12), false);
});

test("not abandoned when declined", () => {
  assert.equal(isAbandoned({ status: "requires_payment_method", last_payment_error: { code: "card_declined" } }, 24, 12), false);
});

test("not abandoned when succeeded", () => {
  assert.equal(isAbandoned({ status: "succeeded" }, 24, 12), false);
});

Case studies

Dashboard clutter

The thousands of incomplete payments

A store's Stripe dashboard showed thousands of Incomplete payments, so real issues were impossible to spot. Most were months of abandoned checkouts that Stripe had not yet cleaned up.

The reconciler cancelled the old abandoned intents and their pending orders, and a daily run kept the dashboard clean from then on. Real incomplete payments now stand out.

Conversion metrics

The conversion rate that looked broken

A shop's reports showed a poor checkout completion rate because every abandoned intent counted as a started checkout that never finished, alongside a long list of pending orders.

Clearing the abandoned intents and orders gave a truthful pending list, and the team could finally trust their conversion numbers when testing changes.

What good looks like

After this runs on a schedule, abandoned checkouts stop piling up. Your Stripe dashboard shows only payments that matter, your WooCommerce pending list holds real orders, and your conversion numbers reflect reality. Pair it with the reconcilers for late payments and declines so every checkout ends in a clean, correct state.

FAQ

What are incomplete PaymentIntents in Stripe?

They are payments that were started but never finished. Stripe shows them as Incomplete because they sit on requires_payment_method or requires_confirmation. Most come from shoppers who opened checkout and left before paying.

Is it safe to cancel abandoned PaymentIntents?

Yes, when the script only cancels intents that are old, never attempted a payment, and have no successful charge. It skips anything that shows a decline or a late success, so a real payment is never cancelled.

Does cancelling the PaymentIntent cancel the order?

The script cancels the matching pending WooCommerce order too, which releases any held stock. It never touches an order that is already paid.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: incomplete PaymentIntents and pending orders accumulating. github.com/woocommerce/woocommerce-gateway-stripe/issues/3154
  2. Stripe docs: the PaymentIntent lifecycle and incomplete states. docs.stripe.com/payments/paymentintents/lifecycle
  3. Stripe docs: how long an incomplete PaymentIntent lasts before Stripe cancels it. docs.stripe.com/api/payment_intents/object

On the solution:

  1. Stripe API: cancel a PaymentIntent with a cancellation reason. docs.stripe.com/api/payment_intents/cancel
  2. Stripe API: list PaymentIntents with a created filter. docs.stripe.com/api/payment_intents/list
  3. WooCommerce REST API: update an order status and add a 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 clean up your dashboard?

If this cleared out the incomplete payments and pending orders, 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