Diagnostic Stripe
manual-capture holds expire before anyone captures them
A customer messages to say the pending charge disappeared from their statement, and they are pleased about it. You go to capture the payment, and Stripe answers charge_expired_for_capture. The order shipped. The authorization is gone, the money was never taken, and no retry exists that can take it now.
Page GET /v1/payment_intents?expand[]=data.latest_charge, keep the ones with capture_method of manual, and read latest_charge.payment_method_details.card.capture_before. That timestamp is when the authorization dies. Anything at requires_capture with capture_before inside the next 48 hours is about to become revenue you do not collect.
For the historical loss, count manual-capture intents at status: "canceled" with cancellation_reason: "automatic". That is Stripe recording an authorization that expired rather than a cancellation anyone asked for.
The problem in plain words
The shape of this failure is that it looks like nothing at all. There is no failed payment, no decline, no error event. The intent quietly changes status, the hold falls off the customer's card, and your order table still says authorized. The first person to notice is usually the customer, who noticed money coming back.
It hurts most in exactly the businesses that chose manual capture on purpose: made-to-order goods, rentals, anything that authorizes at checkout and captures at dispatch. Those are the fulfilment cycles that sometimes take eight days, and the authorization window does not care that the delay was legitimate.
Why it happens
The window is short and it is not yours. The authorization is held by the card network, not by Stripe, and it is valid for roughly seven days for most card-not-present transactions — less in several common cases: about five days for some Visa merchant-initiated transactions and as little as two for most card-present ones. A cron that captures on day seven is correct for one of those and too late for the others.
So the date has to be read, never computed. Stripe puts the real deadline on the charge as capture_before. Any job that derives its own deadline from created plus a fixed number of days is guessing at a number the network already told you, and it guesses wrong for every transaction type outside the one it was written against.
Expiry is silent by design. When the window closes the intent moves to canceled with cancellation_reason of automatic. Nothing failed, so nothing raises. If your webhook handler only listens for payment failures, this state never reaches it.
The refund it generates pollutes your metrics. An expired uncaptured authorization produces a Refund whose reason is expired_uncaptured_charge. Nobody issued it, no customer asked for it, and it lands in the same table your refund rate is computed from — so the integration failure shows up in a report as unhappy customers.
The fix, as a flow
The script expands the charge, because the deadline lives there, and sorts by time remaining rather than by age: two holds authorized the same minute can expire days apart.
How to fix it
Expand the charge, because the deadline is not on the intent
capture_before lives at payment_method_details.card.capture_before on the charge, not on the PaymentIntent. Without expand[]=data.latest_charge the list gives you a charge id and nothing to read, which is the most common reason a check like this reports everything as unknown.
Sort by time remaining, not by age
Two intents created the same minute can have different deadlines. Ordering the work queue by capture_before ascending puts the ones you are about to lose at the top regardless of when they were authorized.
Treat a missing capture_before as unknown, not as safe
Non-card payment methods and unexpanded charges both produce an absent field. Defaulting that to seven days is how a check passes on the exact transactions it was supposed to catch.
Count what has already been lost
Manual-capture intents at canceled with cancellation_reason: "automatic" are the historical bill. Sum their amounts before proposing the fix; the number is usually the argument.
Change the mechanism, not just the schedule
If the fulfilment cycle genuinely runs longer than the window, a faster cron is a patch. Extended authorization buys a longer window on supported cards, and asking Stripe to capture automatically near the expiry moves the deadline off your infrastructure entirely.
How to check it worked
Re-run the script. Nothing should be inside the warning window, and the historical count of automatically canceled holds should stop growing between runs.
python3 stripe_manual_capture_holds.py --warn-hours 48
# 214 manual-capture intent(s): 209 captured, 5 held, 0 expiring, 0 lost
The full code
One paginated GET with a single expansion, and no writes — a restricted key with read access to PaymentIntents and Charges is enough. The classifier is pure and takes the current time as an argument, which is what makes the interesting cases — two hours left, an hour past, no deadline at all — testable without waiting a week for one to occur.
"""Report Stripe manual-capture authorizations about to expire, or already lost.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to PaymentIntents and Charges. The repair is printed, never performed,
because this script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_manual_capture_holds")
API = "https://api.stripe.com/v1"
def classify(intent, now, warn_seconds=48 * 3600):
"""Sort one PaymentIntent by how much of its authorization window is left.
Pure, and `now` is passed in rather than read, so the states that matter
here -- two hours left, an hour past, no deadline at all -- can be tested
without waiting a week for a real one.
The deadline is `capture_before` on the charge, not a fixed number of days
after `created`: the window is roughly 7 days for most card-not-present
transactions but shorter for several common types, so anything computed
locally is wrong for whichever type it was not written against.
Returns (state, detail).
"""
if intent.get("capture_method") != "manual":
return ("automatic", "captured automatically, no hold to lose")
status = intent.get("status")
if status == "succeeded":
return ("captured", "captured inside the window")
if status == "canceled":
if intent.get("cancellation_reason") == "automatic":
return ("lost",
"the authorization expired uncaptured: Stripe canceled it, "
"the hold was released, and no capture can take the money now")
return ("canceled",
"canceled deliberately (%s)"
% (intent.get("cancellation_reason") or "no reason recorded"))
if status != "requires_capture":
return ("open", "status %s: nothing is authorised yet" % (status,))
charge = intent.get("latest_charge")
if not isinstance(charge, dict):
return ("unknown",
"requires_capture with no expanded charge: add "
"expand[]=data.latest_charge, and do not assume seven days")
card = ((charge.get("payment_method_details") or {}).get("card") or {})
capture_before = card.get("capture_before")
if not capture_before:
return ("unknown",
"requires_capture with no capture_before on the charge: the "
"deadline is unknown, which is not the same as far away")
left = int(capture_before) - int(now)
if left <= 0:
return ("expired",
"capture_before passed %dh ago: the hold is gone even if the "
"status has not caught up" % (-left // 3600))
if left <= warn_seconds:
return ("expiring",
"%dh left to capture: past that the funds are released to the "
"cardholder" % (left // 3600))
return ("held", "%dh left to capture" % (left // 3600))
def get(session, path, **params):
r = session.get(API + path, params=params, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
r.raise_for_status()
return r.json()
def intents(session, since, cap):
"""Yield PaymentIntents with their charge expanded, paginating to the cap."""
seen = 0
params = {"limit": 100, "created[gte]": since,
"expand[]": "data.latest_charge"}
while True:
page = get(session, "/payment_intents", **params)
data = page.get("data", [])
for pi in data:
yield pi
seen += 1
if seen >= cap:
return
if not data or not page.get("has_more"):
return
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="how far back to read intents")
ap.add_argument("--warn-hours", type=int, default=48,
help="flag holds with less than this much time left")
ap.add_argument("--max-intents", type=int, default=20000,
help="stop paginating after this many intents")
args = ap.parse_args()
key = os.environ.get("STRIPE_API_KEY")
if not key:
log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + key})
now = int(time.time())
since = now - args.days * 86400
counts, lost_amount = {}, 0
manual = 0
urgent = []
for pi in intents(s, since, args.max_intents):
state, detail = classify(pi, now, args.warn_hours * 3600)
if state == "automatic":
continue
manual += 1
counts[state] = counts.get(state, 0) + 1
if state == "lost":
lost_amount += pi.get("amount") or 0
if state in ("expiring", "expired", "unknown"):
urgent.append((pi, state, detail))
# Soonest deadline first: two intents created the same minute can have very
# different windows, so age is the wrong sort key.
def deadline(row):
charge = row[0].get("latest_charge")
card = ((charge or {}).get("payment_method_details") or {}).get("card") or {}
return card.get("capture_before") or 0
for pi, state, detail in sorted(urgent, key=deadline):
log.warning("%s %-9s %s", pi.get("id", "pi_?"), state, detail)
log.info("%d manual-capture intent(s): %d captured, %d held, %d expiring, "
"%d lost", manual, counts.get("captured", 0), counts.get("held", 0),
counts.get("expiring", 0) + counts.get("expired", 0),
counts.get("lost", 0))
if counts.get("expiring") or counts.get("expired"):
log.warning(" repair: capture now, oldest deadline first:")
log.warning(" POST %s/payment_intents/{id}/capture", API)
if counts.get("lost"):
log.warning(" %d authorization(s) already expired, %d minor unit(s) "
"never collected. Each one also produced a refund with "
"reason expired_uncaptured_charge.",
counts["lost"], lost_amount)
log.warning(" repair: drive the capture job from capture_before rather "
"than a fixed delay, or request extended authorization, or "
"let Stripe capture near expiry.")
return 1 if (counts.get("expiring") or counts.get("expired")
or counts.get("lost") or counts.get("unknown")) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe manual-capture authorizations about to expire, or already lost.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to PaymentIntents and Charges. The repair is printed, never
* performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Sort one PaymentIntent by how much of its authorization window is left. Pure,
* and `now` is passed in rather than read, so two hours left, an hour past, and
* no deadline at all are all testable. The deadline is capture_before on the
* charge, never created plus a fixed number of days. Returns [state, detail].
*/
export function classify(intent, now, warnSeconds = 48 * 3600) {
if (intent.capture_method !== 'manual') {
return ['automatic', 'captured automatically, no hold to lose'];
}
const status = intent.status;
if (status === 'succeeded') return ['captured', 'captured inside the window'];
if (status === 'canceled') {
if (intent.cancellation_reason === 'automatic') {
return ['lost',
'the authorization expired uncaptured: Stripe canceled it, the hold was ' +
'released, and no capture can take the money now'];
}
return ['canceled',
`canceled deliberately (${intent.cancellation_reason ?? 'no reason recorded'})`];
}
if (status !== 'requires_capture') {
return ['open', `status ${status}: nothing is authorised yet`];
}
const charge = intent.latest_charge;
if (!charge || typeof charge !== 'object') {
return ['unknown',
'requires_capture with no expanded charge: add expand[]=data.latest_charge, ' +
'and do not assume seven days'];
}
const card = (charge.payment_method_details ?? {}).card ?? {};
const captureBefore = card.capture_before;
if (!captureBefore) {
return ['unknown',
'requires_capture with no capture_before on the charge: the deadline is ' +
'unknown, which is not the same as far away'];
}
const left = Number(captureBefore) - Number(now);
if (left <= 0) {
return ['expired',
`capture_before passed ${Math.floor(-left / 3600)}h ago: the hold is gone ` +
'even if the status has not caught up'];
}
if (left <= warnSeconds) {
return ['expiring',
`${Math.floor(left / 3600)}h left to capture: past that the funds are ` +
'released to the cardholder'];
}
return ['held', `${Math.floor(left / 3600)}h left to capture`];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function* intents(key, since, cap = 20000) {
let seen = 0;
const params = { limit: 100, 'created[gte]': since,
'expand[]': 'data.latest_charge' };
for (;;) {
const page = await get(key, '/payment_intents', params);
const data = page.data ?? [];
for (const pi of data) {
yield pi;
seen += 1;
if (seen >= cap) return;
}
if (data.length === 0 || !page.has_more) return;
params.starting_after = data[data.length - 1].id;
}
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
const now = Math.floor(Date.now() / 1000);
const days = Number(process.env.DAYS ?? 30);
const warnHours = Number(process.env.WARN_HOURS ?? 48);
const counts = {};
let manual = 0;
let lostAmount = 0;
const urgent = [];
for await (const pi of intents(key, now - days * 86400)) {
const [state, detail] = classify(pi, now, warnHours * 3600);
if (state === 'automatic') continue;
manual += 1;
counts[state] = (counts[state] ?? 0) + 1;
if (state === 'lost') lostAmount += pi.amount ?? 0;
if (state === 'expiring' || state === 'expired' || state === 'unknown') {
urgent.push([pi, state, detail]);
}
}
// Soonest deadline first: age is the wrong sort key, because two intents
// created the same minute can have very different windows.
const deadline = ([pi]) =>
(((pi.latest_charge ?? {}).payment_method_details ?? {}).card ?? {})
.capture_before ?? 0;
for (const [pi, state, detail] of urgent.sort((a, b) => deadline(a) - deadline(b))) {
console.warn(`${pi.id ?? 'pi_?'} ${state.padEnd(9)} ${detail}`);
}
const expiring = (counts.expiring ?? 0) + (counts.expired ?? 0);
console.log(`${manual} manual-capture intent(s): ${counts.captured ?? 0} ` +
`captured, ${counts.held ?? 0} held, ${expiring} expiring, ` +
`${counts.lost ?? 0} lost`);
if (expiring) {
console.warn(' repair: capture now, oldest deadline first:');
console.warn(` POST ${API}/payment_intents/{id}/capture`);
}
if (counts.lost) {
console.warn(` ${counts.lost} authorization(s) already expired, ${lostAmount} ` +
'minor unit(s) never collected. Each one also produced a refund ' +
'with reason expired_uncaptured_charge.');
console.warn(' repair: drive the capture job from capture_before rather than ' +
'a fixed delay, or request extended authorization, or let Stripe ' +
'capture near expiry.');
}
if (expiring || counts.lost || counts.unknown) process.exitCode = 1;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Passing the clock in is what makes this testable at all: an expiry that happens once a week in production is three lines here. The case worth being strict about is the missing capture_before, which has to sort as unknown rather than as safe — a default of seven days would clear exactly the card-present holds whose window is two.
from stripe_manual_capture_holds import classify
NOW = 1_700_000_000
def hold(capture_before, status="requires_capture"):
return {
"capture_method": "manual",
"status": status,
"latest_charge": {
"payment_method_details": {"card": {"capture_before": capture_before}},
},
}
def test_automatic_capture_is_not_this_problem():
assert classify({"capture_method": "automatic"}, NOW)[0] == "automatic"
def test_a_hold_with_days_left_is_held():
state, detail = classify(hold(NOW + 5 * 86400), NOW)
assert state == "held"
assert "120h" in detail
def test_a_hold_inside_the_warning_window_is_expiring():
state, detail = classify(hold(NOW + 6 * 3600), NOW)
assert state == "expiring"
assert "released to the cardholder" in detail
def test_a_passed_deadline_is_expired_even_at_requires_capture():
# Stripe's status can lag the network. The deadline is the fact.
state, _ = classify(hold(NOW - 3600), NOW)
assert state == "expired"
def test_missing_capture_before_is_unknown_not_safe():
state, detail = classify(hold(None), NOW)
assert state == "unknown"
assert "not the same as far away" in detail
def test_automatic_cancellation_is_the_historical_loss():
state, detail = classify({
"capture_method": "manual",
"status": "canceled",
"cancellation_reason": "automatic",
}, NOW)
assert state == "lost"
assert "expired uncaptured" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-manual-capture-holds.mjs';
const NOW = 1700000000;
const hold = (captureBefore, status = 'requires_capture') => ({
capture_method: 'manual',
status,
latest_charge: {
payment_method_details: { card: { capture_before: captureBefore } },
},
});
test('automatic capture is not this problem', () => {
assert.equal(classify({ capture_method: 'automatic' }, NOW)[0], 'automatic');
});
test('a hold with days left is held', () => {
const [state, detail] = classify(hold(NOW + 5 * 86400), NOW);
assert.equal(state, 'held');
assert.match(detail, /120h/);
});
test('a hold inside the warning window is expiring', () => {
const [state, detail] = classify(hold(NOW + 6 * 3600), NOW);
assert.equal(state, 'expiring');
assert.match(detail, /released to the cardholder/);
});
test('a passed deadline is expired even at requires_capture', () => {
assert.equal(classify(hold(NOW - 3600), NOW)[0], 'expired');
});
test('missing capture_before is unknown, not safe', () => {
const [state, detail] = classify(hold(null), NOW);
assert.equal(state, 'unknown');
assert.match(detail, /not the same as far away/);
});
test('automatic cancellation is the historical loss', () => {
const [state, detail] = classify({
capture_method: 'manual',
status: 'canceled',
cancellation_reason: 'automatic',
}, NOW);
assert.equal(state, 'lost');
assert.match(detail, /expired uncaptured/);
});
FAQ
How long is a manual-capture authorization actually valid?
Roughly seven days for most card-not-present transactions, and less for several common cases: around five days for some Visa merchant-initiated transactions and about two for most card-present ones. Because the number varies by transaction type, read capture_before off the charge instead of assuming any of them.
Where does capture_before live?
On the charge, at payment_method_details.card.capture_before, so you need expand[]=data.latest_charge when listing PaymentIntents. The PaymentIntent itself does not carry the deadline.
What happens when the window closes?
Stripe cancels the intent with cancellation_reason 'automatic' and the hold is released back to the cardholder. Nothing fails, so nothing raises, and a capture attempt afterwards returns charge_expired_for_capture.
Why is my refund rate wrong?
An expired uncaptured authorization produces a Refund whose reason is expired_uncaptured_charge. Nobody issued it and no customer asked for it, so counting it as a refund turns an integration failure into a customer-satisfaction number.
What if fulfilment genuinely takes longer than the window?
Then a faster cron only shrinks the losses. Extended authorization lengthens the window on supported cards, and letting Stripe capture automatically near the expiry moves the deadline off your own scheduler, which is where it keeps being missed.
Related field notes
- PaymentIntents sit in requires_payment_method for weeks
- 3DS handoff breaks and requires_action intents pile up
- Refunds sit failed or requires_action and nobody notices
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Place a hold on a payment method — Stripe Docs
- The PaymentIntent object — Stripe API reference
- Error codes — Stripe Docs
- The refund object — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.