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.

Python and Node.js Runs on a schedule Safe by default (dry run)
Selective focus photo of brown and blue hourglass on stones
Photo by Aron Visuals on Unsplash
The short answer

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.

Card authorized money reserved Order on hold awaiting capture 7 days no one captures Hold expires money released Unpaid order
The money is only reserved at the authorize step. If no capture happens within the window, Stripe releases the hold and the sale is lost.

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 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.

The key insight

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.

Scheduled job a few times a day List awaiting capture requires_capture intents Read order_id from metadata On hold and amount ok? yes no, skip Capture and finish mark processing + note
The job captures only orders that are still on hold and whose amount matches the authorization. Everything else is left alone.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

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

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.

decide.py
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")
decide.js
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"];
}
4

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.

apply.py
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()
apply.js
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.`,
    }),
  });
}
5

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.

Run it safe

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_authorized.py
"""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-authorized.js
/**
 * 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.

test_capture_decide.py
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"
capture.test.js
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

Staff change

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.

Fraud review

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.

What good looks like

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:

  1. 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
  2. Stripe docs: authorizations expire after about seven days if not captured. docs.stripe.com/payments/capture-later
  3. WooCommerce docs: the Stripe gateway capture setting, authorize now and capture later. woocommerce.com/document/stripe

On the solution:

  1. Stripe API: capture a PaymentIntent. docs.stripe.com/api/payment_intents/capture
  2. Stripe API: list PaymentIntents with a created filter and auto pagination. docs.stripe.com/api/payment_intents/list
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes