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.
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.
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.
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.
Build it step by step
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.
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"
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";
}
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.
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
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 };
}
}
}
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.
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.")
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.`);
}
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 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 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.
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"
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
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.
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.
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:
- WooCommerce Stripe plugin issue: PaymentIntent stuck on requires_action after 3D Secure. github.com/woocommerce/woocommerce-gateway-stripe/issues/850
- WooCommerce Stripe plugin issue: 3D Secure orders not completing on return. github.com/woocommerce/woocommerce-gateway-stripe/issues/877
- Stripe docs: the PaymentIntent lifecycle and requires_action. docs.stripe.com/payments/paymentintents/lifecycle
On the solution:
- Stripe API: list PaymentIntents and read their status. docs.stripe.com/api/payment_intents/list
- Stripe docs: verify a PaymentIntent status before acting. docs.stripe.com/payments/payment-intents/verifying-status
- 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.
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