Reconciler Payment lifecycle
WooCommerce authorized Stripe charges that are never captured
Your store is set to authorize the card now and capture the money later, maybe so you can check stock or screen for fraud first. The order goes on hold with the money reserved, waiting for someone to press capture. Then it gets forgotten. About seven days later Stripe releases the hold, the reserved money vanishes, and you shipped or owe an order you were never paid for. Here is why it happens and a small job that captures the valid ones before the window closes.
With manual capture, the Stripe charge is only authorized, not taken. The order sits on hold and the hold expires in about seven days. Run a small Python or Node.js job on a schedule that lists PaymentIntents in requires_capture, matches each to its order by metadata.order_id, and captures any order still on hold whose amount matches, then moves it to Processing. Full code, tests, and a dry run guard are below.
The problem in plain words
Stripe can split a payment into two steps. First it authorizes the card, which reserves the money but does not move it. Later you capture, which actually takes the money. WooCommerce uses this when the Stripe gateway is set to authorize now and capture later.
The catch is that an authorization does not last forever. Stripe holds it for about seven days, then lets it go. If nobody captures the order in that time, the reserved money is released back to the buyer. The order is still on hold in WooCommerce, but the chance to collect the money is gone.
Why it happens
Manual capture is a useful setting, but it puts a human step in the middle of every order, and human steps get missed. A few common ways stores end up with expired authorizations:
- The shop turned on the authorize now, capture later option in the Stripe gateway settings and then forgot that on hold orders need a manual capture.
- A staff member who used to capture orders left, and no one took over the daily check.
- A busy weekend buried the on hold orders under new ones, so the oldest quietly aged past the seven day window.
- An order was held for a fraud or stock review that dragged on longer than a week.
The setting itself is documented by WooCommerce and Stripe, and the seven day authorization window is a Stripe rule, not a WooCommerce bug. The problem is that nothing reminds you to capture, so the fix is to add that reminder as a job. See the citations at the end for the exact docs.
Stripe is the source of truth for money. If Stripe shows a PaymentIntent in requires_capture and the order is still on hold, that is money you are allowed to take but have not. A job that captures those before the window closes turns a silent loss into a collected sale.
The fix, as a flow
We do not touch the live checkout. We add a job that lists the authorized but uncaptured payments, checks each matching order, and captures the ones that are still on hold and whose amount matches. Capturing moves the real money and lets us finish the order the way a manual capture would.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="168" # 7 days, the authorization window
export DRY_RUN="true" # start safe, change to false to capture
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="168" // 7 days, the authorization window
export DRY_RUN="true" // start safe, change to false to capture
List the payments still awaiting capture
Ask Stripe for PaymentIntents created in your lookback window and keep only the ones with status requires_capture. That status means the card was authorized and the money is still reserved, waiting for you. The WooCommerce Stripe plugin writes the order ID into the PaymentIntent metadata, so that is how we find the order.
import os, time, stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def recent_uncaptured(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.status == "requires_capture" and intent.metadata.get("order_id"):
yield intent
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function* recentUncaptured(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.status === "requires_capture" && intent.metadata.order_id) yield intent;
}
}
Decide, with one pure function
Keep the decision in its own function that takes an order and an intent and returns an action. An authorized PaymentIntent reports the held amount in amount, not amount_received, because nothing has been captured yet. The rule is simple. If the order is not on hold, skip it. If the amount does not match, skip and warn. Otherwise, capture.
CAPTURABLE_STATUSES = {"on-hold", "pending"}
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(order, intent):
if intent.get("status") != "requires_capture":
return ("skip", "not awaiting capture")
if order is None:
return ("orphan", "order not found")
if order["status"] not in CAPTURABLE_STATUSES:
return ("skip", "order not awaiting capture")
if abs(order_amount_minor(order) - intent["amount"]) > 1:
return ("mismatch", "amount does not match")
return ("capture", "authorized in Stripe, still awaiting capture")
const CAPTURABLE_STATUSES = new Set(["on-hold", "pending"]);
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
export function decide(order, intent) {
if (intent.status !== "requires_capture") return ["skip", "not awaiting capture"];
if (!order) return ["orphan", "order not found"];
if (!CAPTURABLE_STATUSES.has(order.status)) return ["skip", "order not awaiting capture"];
if (Math.abs(orderAmountMinor(order) - intent.amount) > 1) {
return ["mismatch", "amount does not match"];
}
return ["capture", "authorized in Stripe, still awaiting capture"];
}
Capture the payment and finish the order
When the action is capture, call PaymentIntent.capture to take the reserved money. Stripe returns the charge, and newer API versions put its id in latest_charge. Then set the order to Processing, save the charge id, and add a note so the shop manager can see it was captured by the job. Status and notes go through the REST API, so HPOS is handled for you.
import requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
def capture(order_id, intent):
charge = stripe.PaymentIntent.capture(intent["id"])
charge_id = charge.get("latest_charge") or intent["id"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"status": "processing", "transaction_id": charge_id},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Captured Stripe PaymentIntent {intent['id']} before the "
f"authorization expired. Marked processing by the capture job."},
auth=AUTH, timeout=30,
).raise_for_status()
async function capture(orderId, intent) {
const charge = await stripe.paymentIntents.capture(intent.id);
const chargeId = charge.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: `Captured Stripe PaymentIntent ${intent.id} before the authorization ` +
`expired. Marked processing by the capture job.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the job only reports which orders it would capture. Read the output, agree with it, then switch it off. Capturing moves real money, so run it a few times a day rather than once a week, to leave room before the hold expires.
Always start with DRY_RUN=true. Capturing takes money from the buyer, so only capture orders you truly intend to fulfill. If you use manual capture to screen orders, keep the human review, and let the job capture only the ones that pass and are still on hold.
The full code
Here is the complete job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only captures orders that are still on hold with a matching amount.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Capture WooCommerce orders whose Stripe authorization was never captured.
Run on a schedule. Safe to run again and again.
"""
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("capture_authorized")
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"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CAPTURABLE_STATUSES = {"on-hold", "pending"}
def recent_uncaptured(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.status == "requires_capture" and intent.metadata.get("order_id"):
yield intent
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 order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(order, intent):
if intent.get("status") != "requires_capture":
return ("skip", "not awaiting capture")
if order is None:
return ("orphan", "order not found")
if order["status"] not in CAPTURABLE_STATUSES:
return ("skip", "order not awaiting capture")
if abs(order_amount_minor(order) - intent["amount"]) > 1:
return ("mismatch", "amount does not match")
return ("capture", "authorized in Stripe, still awaiting capture")
def capture(order_id, intent):
charge = stripe.PaymentIntent.capture(intent["id"])
charge_id = charge.get("latest_charge") or intent["id"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"status": "processing", "transaction_id": charge_id},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Captured Stripe PaymentIntent {intent['id']} before the "
f"authorization expired. Marked processing by the capture job."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
captured = 0
for intent in recent_uncaptured(LOOKBACK_HOURS):
order_id = intent.metadata["order_id"]
order = get_order(order_id)
action, reason = decide(order, intent)
if action == "orphan":
log.warning("Intent %s points to order %s which is missing", intent.id, order_id)
continue
if action in ("skip", "mismatch"):
if action == "mismatch":
log.warning("Order %s amount mismatch: %s", order_id, reason)
continue
log.info("Order %s: %s. %s", order_id, reason, "would capture" if DRY_RUN else "capturing")
if not DRY_RUN:
capture(order_id, intent)
captured += 1
log.info("Done. %d order(s) %s.", captured, "to capture" if DRY_RUN else "captured")
if __name__ == "__main__":
run()
/**
* Capture WooCommerce orders whose Stripe authorization was never captured.
* Run on a schedule. Safe to run again and again.
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CAPTURABLE_STATUSES = new Set(["on-hold", "pending"]);
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* recentUncaptured(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.status === "requires_capture" && intent.metadata.order_id) yield intent;
}
}
function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
function decide(order, intent) {
if (intent.status !== "requires_capture") return ["skip", "not awaiting capture"];
if (!order) return ["orphan", "order not found"];
if (!CAPTURABLE_STATUSES.has(order.status)) return ["skip", "order not awaiting capture"];
if (Math.abs(orderAmountMinor(order) - intent.amount) > 1) {
return ["mismatch", "amount does not match"];
}
return ["capture", "authorized in Stripe, still awaiting capture"];
}
async function capture(orderId, intent) {
const charge = await stripe.paymentIntents.capture(intent.id);
const chargeId = charge.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: `Captured Stripe PaymentIntent ${intent.id} before the authorization ` +
`expired. Marked processing by the capture job.`,
}),
});
}
async function run() {
let captured = 0;
for await (const intent of recentUncaptured(LOOKBACK_HOURS)) {
const orderId = intent.metadata.order_id;
const order = await woo(`/orders/${orderId}`);
const [action, reason] = decide(order, intent);
if (action === "orphan") { console.warn(`Intent ${intent.id} points to missing order ${orderId}`); continue; }
if (action === "skip" || action === "mismatch") {
if (action === "mismatch") console.warn(`Order ${orderId} amount mismatch: ${reason}`);
continue;
}
console.log(`Order ${orderId}: ${reason}. ${DRY_RUN ? "would capture" : "capturing"}`);
if (!DRY_RUN) await capture(orderId, intent);
captured++;
}
console.log(`Done. ${captured} order(s) ${DRY_RUN ? "to capture" : "captured"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The decision rule is the part most worth testing, because it decides whether real money gets captured. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from capture_authorized import decide
def intent(**over):
base = {"status": "requires_capture", "amount": 5000, "id": "pi_1"}
base.update(over)
return base
def test_capture_when_on_hold_and_amount_matches():
order = {"status": "on-hold", "total": "50.00"}
assert decide(order, intent())[0] == "capture"
def test_skip_when_intent_not_awaiting_capture():
order = {"status": "on-hold", "total": "50.00"}
assert decide(order, intent(status="succeeded"))[0] == "skip"
def test_skip_when_order_already_processing():
order = {"status": "processing", "total": "50.00"}
assert decide(order, intent())[0] == "skip"
def test_mismatch_when_amount_differs():
order = {"status": "on-hold", "total": "40.00"}
assert decide(order, intent())[0] == "mismatch"
def test_orphan_when_order_missing():
assert decide(None, intent())[0] == "orphan"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./capture-authorized.js";
const intent = (over = {}) => ({ status: "requires_capture", amount: 5000, id: "pi_1", ...over });
test("capture when on hold and amount matches", () => {
assert.equal(decide({ status: "on-hold", total: "50.00" }, intent())[0], "capture");
});
test("skip when intent not awaiting capture", () => {
assert.equal(decide({ status: "on-hold", total: "50.00" }, intent({ status: "succeeded" }))[0], "skip");
});
test("skip when order already processing", () => {
assert.equal(decide({ status: "processing", total: "50.00" }, intent())[0], "skip");
});
test("mismatch when amount differs", () => {
assert.equal(decide({ status: "on-hold", total: "40.00" }, intent())[0], "mismatch");
});
test("orphan when order missing", () => {
assert.equal(decide(null, intent())[0], "orphan");
});
Case studies
The capture step that walked out the door
A furniture store authorized every order and captured it by hand once the item was confirmed in the warehouse. The one person who did that left, and for two weeks nobody captured anything. Dozens of authorizations quietly expired, and the shop shipped goods it was never paid for.
A job on a twice daily schedule now captures the on hold orders that passed the warehouse check before their window closes. The loss stopped the day it went live.
The review queue that aged out
A store held high value orders for a manual fraud review. Good orders sometimes sat in the queue for over a week while staff caught up, and by the time they were approved the authorization was already gone.
The team ran the job in dry run first, saw exactly which approved orders were still capturable, then let it capture them. Now an approved order is captured within hours, well inside the window.
After this runs on a schedule, an authorized order is no longer a race against a hidden clock. Approved orders are captured automatically before the hold expires, and the only ones left on hold are the ones a human has not cleared yet. No more silent losses from a forgotten capture.
FAQ
Why is my WooCommerce order stuck on hold with a Stripe authorization?
When the Stripe gateway is set to authorize now and capture later, the order stays on hold until someone captures the payment. If no one does, Stripe releases the hold after about seven days and the money is gone. A job that captures the valid authorized orders before that window closes fixes it.
How long does a Stripe authorization last before it expires?
A card authorization on Stripe generally lasts about seven days before it expires and the held funds are released back to the buyer. After that you cannot capture it and must charge the card again. The exact window can vary by card and method.
Is it safe to capture Stripe charges with a script?
Yes, when the script only captures orders that are still on hold, whose PaymentIntent is in requires_capture, and whose amount matches the order total. Start in dry run mode to review the list before it captures anything.
Related field notes
Citations
On the problem:
- Stripe docs: place a hold on a payment method, the separate authorize and capture flow. docs.stripe.com/payments/place-a-hold-on-a-payment-method
- Stripe docs: authorizations expire after about seven days if not captured. docs.stripe.com/payments/capture-later
- WooCommerce docs: the Stripe gateway capture setting, authorize now and capture later. woocommerce.com/document/stripe
On the solution:
- Stripe API: capture a PaymentIntent. docs.stripe.com/api/payment_intents/capture
- Stripe API: list PaymentIntents with a created filter and auto pagination. docs.stripe.com/api/payment_intents/list
- WooCommerce REST API: update an order and add an order 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 save you a lost sale?
If this kept an authorization from expiring on you, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi