Reconciler Payment lifecycle

WooCommerce orders stuck on 3D Secure with Stripe

A buyer reached the 3D Secure step, then closed the tab or lost the bank redirect. The Stripe PaymentIntent is frozen on requires_action, the WooCommerce order sits on Pending, and the stock stays held. Some of these quietly complete a few minutes later and become paid but still Pending. This guide sweeps them all, completes the ones that paid, and frees the ones that did not.

Python and Node.js Runs on a schedule Frees held stock
Woman holding android smartphone
Photo by Jonas Leupe on Unsplash
The short answer

When 3D Secure is needed, the PaymentIntent waits on requires_action until the buyer finishes the bank step. If they never do, the order stays Pending and holds stock. List PaymentIntents that are still waiting, match each to its order, and act by age. If it later moved to succeeded, complete the order. If it is old and still incomplete, mark the order failed so the stock is released. The full code is below.

The problem in plain words

Many cards in Europe and beyond require a second step called 3D Secure, where the buyer confirms the payment with their bank. While that step is open, the Stripe PaymentIntent sits on a status called requires_action. The order cannot complete yet, so WooCommerce keeps it on Pending and holds the stock for it.

Two things go wrong from here. Some buyers never finish the bank step, so the order hangs forever and the stock stays locked. Other buyers finish it a little late, after they have already left your store, so the payment succeeds on Stripe but the order is still Pending because the message that would complete it was missed.

3D Secure step bank confirmation requires_action waiting on the buyer Buyer abandons or loses redirect Pending stock held
The order waits on the bank step. When the buyer never returns, it hangs and keeps its stock locked.

Why it happens

The WooCommerce Stripe plugin has reported cases where the 3D Secure return does not complete cleanly, often from cookie settings or a CDN dropping the return parameters, leaving the PaymentIntent on requires_action or falling back to requires_payment_method. The plugin also has reports of payments that succeed after the buyer leaves, which then need the webhook to finish the order. The citations at the end link the threads.

The key insight

Stripe always knows the real state of the payment. A PaymentIntent that is still requires_action after a few hours is almost certainly abandoned, and one that quietly moved to succeeded is a paid order waiting to be finished. Reading that status lets one script both complete the winners and free the stock from the losers.

The fix, as a flow

We list PaymentIntents that are still waiting, from a recent window, and match each to its order. If the intent succeeded, we complete the order the way the webhook would. If it is still incomplete and older than a threshold you set, we mark the order failed, which releases the held stock. Recent ones are left alone so slow buyers still have time.

List waiting PaymentIntents what state? and how old? succeeded later complete the order old and incomplete fail, release stock
One sweep completes the payments that came through and frees the stock from the ones that never will.

Build it step by step

1

Decide the action with a pure function

Keep the rule in one function. It takes the intent status and its age in hours, and returns what to do. Succeeded means complete. Still waiting and old means fail. Still waiting but recent means wait. Keeping it pure makes it easy to test.

classify.py
WAITING = {"requires_action", "requires_payment_method", "requires_confirmation", "processing"}

def classify(status, age_hours, threshold_hours):
    if status == "succeeded":
        return "complete"
    if status in WAITING:
        return "fail" if age_hours >= threshold_hours else "wait"
    return "wait"
classify.js
const WAITING = new Set(["requires_action", "requires_payment_method", "requires_confirmation", "processing"]);

export function classify(status, ageHours, thresholdHours) {
  if (status === "succeeded") return "complete";
  if (WAITING.has(status)) return ageHours >= thresholdHours ? "fail" : "wait";
  return "wait";
}
2

List the waiting PaymentIntents

Ask Stripe for PaymentIntents created in your window, and keep the ones that are still waiting or that succeeded but might not have finished the order. Each one carries the order ID in its metadata, which is how we find the order.

step2.py
import time, stripe

def candidate_intents(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") and intent.status != "canceled":
            age_hours = (time.time() - intent["created"]) / 3600
            yield intent, age_hours
step2.js
export async function* candidateIntents(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 && intent.status !== "canceled") {
      const ageHours = (Date.now() / 1000 - intent.created) / 3600;
      yield { intent, ageHours };
    }
  }
}
3

Complete the winners and fail the losers

For a succeeded intent whose order is not yet paid, move the order to Processing and save the charge. For an old intent still waiting, move the order to failed, which returns the held stock to your catalog. Both go through the REST API, so High Performance Order Storage is handled for you.

apply.py
def complete_order(order_id, intent):
    charge_id = intent.get("latest_charge") or intent["id"]
    put_order(order_id, {"status": "processing", "transaction_id": charge_id})
    add_note(order_id, f"3D Secure payment {intent['id']} completed later. Marked processing.")

def fail_order(order_id, intent):
    put_order(order_id, {"status": "failed"})
    add_note(order_id, f"3D Secure was never completed for {intent['id']}. Marked failed to release stock.")
apply.js
async function completeOrder(orderId, intent) {
  const chargeId = intent.latest_charge || intent.id;
  await putOrder(orderId, { status: "processing", transaction_id: chargeId });
  await addNote(orderId, `3D Secure payment ${intent.id} completed later. Marked processing.`);
}

async function failOrder(orderId, intent) {
  await putOrder(orderId, { status: "failed" });
  await addNote(orderId, `3D Secure was never completed for ${intent.id}. Marked failed to release stock.`);
}
Give slow buyers time

Set the threshold to a few hours, not a few minutes. A short window would fail orders from buyers who are simply slow to open their banking app. Always run with DRY_RUN=true first and read which orders it would complete and which it would fail.

The full code

The complete reconciler sweeps waiting PaymentIntents, completes the ones that paid, and fails the old ones that never finished. It skips orders that are already paid, so it is safe to run every few minutes.

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

resolve_3ds.py
"""Resolve WooCommerce orders stuck on 3D Secure with Stripe.
Complete the ones that paid, fail the old ones that never finished.
Run on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/woocommerce/orders-stuck-requires-action-3ds/
"""
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("resolve_3ds")

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

PAID_STATUSES = {"processing", "completed"}
WAITING = {"requires_action", "requires_payment_method", "requires_confirmation", "processing"}


def classify(status, age_hours, threshold_hours):
    if status == "succeeded":
        return "complete"
    if status in WAITING:
        return "fail" if age_hours >= threshold_hours else "wait"
    return "wait"


def candidate_intents(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") and intent.status != "canceled":
            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():
    completed = failed = 0
    for intent, age_hours in candidate_intents(LOOKBACK_HOURS):
        order_id = intent.metadata["order_id"]
        action = classify(intent.status, age_hours, THRESHOLD_HOURS)
        if action == "wait":
            continue
        order = get_order(order_id)
        if not order or order["status"] in PAID_STATUSES:
            continue
        if action == "complete":
            log.info("Order %s: 3DS paid later. %s", order_id, "would complete" if DRY_RUN else "completing")
            if not DRY_RUN:
                charge_id = intent.get("latest_charge") or intent["id"]
                put_order(order_id, {"status": "processing", "transaction_id": charge_id})
                add_note(order_id, f"3D Secure payment {intent['id']} completed later. Marked processing.")
            completed += 1
        elif action == "fail":
            log.info("Order %s: 3DS never finished. %s", order_id, "would fail" if DRY_RUN else "failing")
            if not DRY_RUN:
                put_order(order_id, {"status": "failed"})
                add_note(order_id, f"3D Secure was never completed for {intent['id']}. Marked failed to release stock.")
            failed += 1
    log.info("Done. %d completed, %d failed %s.", completed, failed, "(dry run)" if DRY_RUN else "")


if __name__ == "__main__":
    run()
resolve-3ds.js
/**
 * Resolve WooCommerce orders stuck on 3D Secure with Stripe.
 * Complete the ones that paid, fail the old ones that never finished.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/orders-stuck-requires-action-3ds/
 */
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 || 48);
const THRESHOLD_HOURS = Number(process.env.THRESHOLD_HOURS || 6);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);
const WAITING = new Set(["requires_action", "requires_payment_method", "requires_confirmation", "processing"]);

export function classify(status, ageHours, thresholdHours) {
  if (status === "succeeded") return "complete";
  if (WAITING.has(status)) return ageHours >= thresholdHours ? "fail" : "wait";
  return "wait";
}

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* candidateIntents(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 && intent.status !== "canceled") {
      const ageHours = (Date.now() / 1000 - intent.created) / 3600;
      yield { intent, ageHours };
    }
  }
}

async function run() {
  let completed = 0, failed = 0;
  for await (const { intent, ageHours } of candidateIntents(LOOKBACK_HOURS)) {
    const orderId = intent.metadata.order_id;
    const action = classify(intent.status, ageHours, THRESHOLD_HOURS);
    if (action === "wait") continue;
    const order = await woo(`/orders/${orderId}`);
    if (!order || PAID_STATUSES.has(order.status)) continue;
    if (action === "complete") {
      console.log(`Order ${orderId}: 3DS paid later. ${DRY_RUN ? "would complete" : "completing"}`);
      if (!DRY_RUN) {
        const chargeId = intent.latest_charge || intent.id;
        await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "processing", transaction_id: chargeId }) });
        await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note: `3D Secure payment ${intent.id} completed later. Marked processing.` }) });
      }
      completed++;
    } else if (action === "fail") {
      console.log(`Order ${orderId}: 3DS never finished. ${DRY_RUN ? "would fail" : "failing"}`);
      if (!DRY_RUN) {
        await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "failed" }) });
        await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note: `3D Secure was never completed for ${intent.id}. Marked failed to release stock.` }) });
      }
      failed++;
    }
  }
  console.log(`Done. ${completed} completed, ${failed} failed ${DRY_RUN ? "(dry run)" : ""}.`);
}

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

Add a test

The classify rule decides whether an order is completed, failed, or left alone, so it is the piece to test. It is pure, so the test just passes in a status and an age.

test_classify.py
from resolve_3ds import classify


def test_complete_when_succeeded():
    assert classify("succeeded", 0.1, 6) == "complete"


def test_fail_when_old_and_waiting():
    assert classify("requires_action", 8, 6) == "fail"


def test_wait_when_recent_and_waiting():
    assert classify("requires_action", 1, 6) == "wait"


def test_wait_for_unknown_status():
    assert classify("canceled", 100, 6) == "wait"
classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classify } from "./resolve-3ds.js";

test("complete when succeeded", () => {
  assert.equal(classify("succeeded", 0.1, 6), "complete");
});

test("fail when old and waiting", () => {
  assert.equal(classify("requires_action", 8, 6), "fail");
});

test("wait when recent and waiting", () => {
  assert.equal(classify("requires_action", 1, 6), "wait");
});

test("wait for an unknown status", () => {
  assert.equal(classify("canceled", 100, 6), "wait");
});

Case studies

Lost redirect

The bank app that never came back

A buyer was sent to their bank app for 3D Secure, approved the payment, but the return link to the store was stripped by a CDN. Stripe eventually marked the payment succeeded, yet the order stayed Pending because the completion never ran.

The reconciler saw the succeeded intent, matched the order, and moved it to Processing with a clear note. The customer got their confirmation email at last.

Abandoned checkout

The held stock on a sold-out item

During a limited drop, several buyers reached the 3D Secure step and gave up. Their orders held the last units, so real buyers saw the item as out of stock.

With the threshold set to four hours, the script failed the abandoned orders and released their stock back into the catalog, and the units sold to buyers who actually finished checkout.

What good looks like

After this runs on a schedule, 3D Secure stops leaving a trail of frozen orders. Late payments finish on their own, abandoned ones free their stock within hours, and your Pending list reflects real, active checkouts rather than ghosts.

FAQ

What does requires_action mean on a Stripe PaymentIntent?

It means the payment needs another step before it can complete, almost always the 3D Secure check with the buyer's bank. Until the buyer finishes that step, the PaymentIntent waits and the WooCommerce order stays on Pending.

Why do some 3D Secure orders complete on their own later?

The buyer sometimes finishes the bank step after leaving your store, so the PaymentIntent moves to succeeded a few minutes later. If the webhook is missed at that moment, the order is paid on Stripe but still Pending in WooCommerce, which a reconciler can fix.

How long should I wait before failing a stuck 3D Secure order?

A few hours is a safe window. That gives slow buyers time to finish the bank step while still freeing held stock for genuinely abandoned checkouts within the same day.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: PaymentIntent stuck on requires_action after 3D Secure. github.com/woocommerce/woocommerce-gateway-stripe/issues/850
  2. WooCommerce Stripe plugin issue: 3D Secure orders not completing on return. github.com/woocommerce/woocommerce-gateway-stripe/issues/877
  3. Stripe docs: the PaymentIntent lifecycle and requires_action. docs.stripe.com/payments/paymentintents/lifecycle

On the solution:

  1. Stripe API: list PaymentIntents and read their status. docs.stripe.com/api/payment_intents/list
  2. Stripe docs: verify a PaymentIntent status before acting. docs.stripe.com/payments/payment-intents/verifying-status
  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 free your stuck orders?

If this cleared your 3D Secure backlog and freed 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