Diagnostic Stripe
refunds nobody issued with reason expired_uncaptured_charge
Somebody in the monthly review asks why the refund rate went from 1.2% to 3.1%. Support has no extra tickets. Nobody remembers approving a wave of refunds, and the finance export shows money going back out that never came in. Every one of those refunds was written by Stripe, and no customer asked for a single one.
Paginate GET /v1/refunds?limit=100&created[gte]=<90 days ago> and group the results by reason. The value expired_uncaptured_charge is set by Stripe itself when an authorization runs out of time before anyone captures it. It is the only reason on the Refund object that no human in your business can choose.
Weight the groups by amount, not by count, and express the expired total as a share of everything refunded in the window. Then confirm each one against its charge: GET /v1/charges/{id} should report captured: false, which is the proof that no money was ever collected to give back.
The problem in plain words
Refund rate is a metric that leaves engineering. It goes into board decks, into support QA, into the conversation about whether the product is disappointing people. These refunds are not that. They are an integration failure filed in a customer-satisfaction column, and the two get argued about for months because the numbers are real and the explanation is nowhere near them.
The object itself gives nothing away. It has an amount, a currency, a charge, a status of succeeded, and it appears in the Dashboard's refund list next to the ones your support team issued this morning. Any report built on GET /v1/refunds without grouping by reason counts it, and almost no report groups by reason.
The other half of the damage is the reconciliation side. A refund normally has a settled payment behind it. These do not, because the authorization was released rather than captured, so the ledger shows a return with no matching receipt and somebody spends a day trying to find the missing money.
Why it happens
Stripe writes the refund, not you. When an uncaptured authorization reaches the end of its window, Stripe records the release as a Refund and stamps it reason: "expired_uncaptured_charge". No API call of yours created it, so there is no code path to search for and no log line on your side that mentions it.
The reason field is the only thing separating it from a real refund. Status, amount, currency, charge id and creation time all look exactly like a refund a person issued. If your reporting selects on status and sums amounts, which is the obvious way to write it, the two populations are already merged before anyone can tell them apart.
It is a lagging indicator of a different bug. The refund appears at the end of the authorization window, days after the capture that never happened. By the time it lands, the deploy or the queue backlog that caused it is old news, so the spike in refunds and the cause sit too far apart in time to be connected by eye.
The charge is where the proof lives. captured: false on the underlying charge is what turns "we think these are automatic" into a fact you can put in a ticket. It is one extra lookup per candidate and it is the difference between an argument and a finding.
Nothing alerts on it. The refund is not a failure, so no error event is emitted for it. There is a charge.refund.updated event you can subscribe to, but nobody subscribes to refund events looking for good news, which is what this superficially is.
The fix, as a flow
The script starts from the refunds rather than the charges, because the reason code that separates a returned payment from a released hold only exists on the refund, and then it goes to the charge for the proof.
How to fix it
Page the refunds, not the charges
Start from GET /v1/refunds over a 90-day window. Starting from charges means scanning far more objects to find the same set, and the reason code you actually want to count only exists on the refund.
Group by reason and weight by amount
Counts understate this badly. Expired authorizations skew toward the larger, slower orders — the made-to-order and rental flows that chose manual capture in the first place — so five of them can outweigh two hundred small customer refunds. Report money, then count.
Confirm each candidate against its charge
Fetch GET /v1/charges/{id} for the charge on each expired refund and check captured. A false confirms the story. A true means something else produced that reason value and you should look at the charge before counting it, rather than assuming the label.
Express the result as a share of refunded value
"Nine refunds" persuades nobody. "31% of everything we refunded last quarter was authorizations we let expire, and no customer asked for any of it" ends the conversation about product dissatisfaction in one line.
Split the metric, then go and fix the capture pipeline
Exclude expired_uncaptured_charge from the customer-facing refund rate and give it its own operational number. That stops the misattribution but it does not stop the loss — for the forward-looking half, the deadline you are missing is on the charge as capture_before, which is a check of its own.
How to check it worked
Re-run over a window that starts after the capture pipeline was fixed. The expired share should fall to zero, and the refund rate you report should drop to whatever your customers were actually asking for all along.
python3 stripe_expired_capture_refunds.py --days 90 --verify-charges
# 412 refund(s) in 90 day(s): 0 expired uncaptured, 0.0% of refunded value
The full code
One paginated GET over Refunds, plus one small lookup per candidate charge when you ask for it, and no writes — a restricted key with read access to Refunds and Charges is enough. The classifier is pure and takes the charge as a separate argument, because the case that matters most is the one where the charge was not fetched: an unverified expired refund is not the same finding as a confirmed one, and quietly treating it as such is how a report gets contradicted in the meeting it was written for.
"""Report Stripe refunds that Stripe wrote itself when an authorization expired.
Read only. A paginated GET over Refunds and one lookup per candidate charge, no
writes: give this a RESTRICTED key with read access to Refunds 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_expired_capture_refunds")
API = "https://api.stripe.com/v1"
# Set by Stripe when an uncaptured authorization runs out of time. It is the one
# reason value on the Refund object that nobody in your business can choose, and
# so the one that should never be counted in a customer-facing refund rate.
EXPIRED = "expired_uncaptured_charge"
# The reasons a person picks. Everything here is a real refund.
CUSTOMER_REASONS = ("requested_by_customer", "duplicate", "fraudulent")
def classify(refund, charge=None):
"""Sort one Refund into money a human gave back and money that fell off a card.
Pure, so the whole rule set is readable here and testable without a network.
`charge` is the Charge this refund belongs to, or None when it was not looked
up. That distinction is deliberate: an expired refund confirmed against
`captured == false` is evidence, and an unconfirmed one is a candidate, and
collapsing the two is how a finding gets contradicted later.
Returns (state, detail).
"""
reason = refund.get("reason")
if reason == EXPIRED:
if charge is None:
return ("expired-unverified",
"Stripe wrote this when the authorization expired, but the "
"charge was not fetched, so captured is unconfirmed")
captured = charge.get("captured")
if captured is False:
return ("expired",
"the authorization expired uncaptured: nobody issued this "
"refund and no customer asked for it")
return ("inconsistent",
"reason says the authorization expired but the charge reports "
"captured=%r: read the charge before counting this one" % (captured,))
if reason in CUSTOMER_REASONS:
return ("customer", "a real refund (%s), belongs in the refund rate" % reason)
if reason is None:
return ("unlabelled",
"no reason recorded: issued through the API or the Dashboard "
"without one, so it counts as a real refund until proven otherwise")
return ("other", "unrecognised reason %r, left in the rate" % (reason,))
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 page_refunds(session, since, limit):
"""Yield refunds created since a unix timestamp, newest first."""
seen = 0
params = {"limit": 100, "created[gte]": since}
while True:
page = get(session, "/refunds", **params)
rows = page.get("data", [])
for refund in rows:
yield refund
seen += 1
if not page.get("has_more") or not rows or seen >= limit:
break
params["starting_after"] = rows[-1]["id"]
def add(bucket, currency, amount):
bucket[currency] = bucket.get(currency, 0) + (amount or 0)
def money(bucket):
if not bucket:
return "nothing"
return ", ".join("%.2f %s" % (v / 100.0, k.upper()) for k, v in sorted(bucket.items()))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=90,
help="how far back to scan refunds (default 90)")
ap.add_argument("--max-refunds", type=int, default=2000,
help="stop paginating after this many refunds")
ap.add_argument("--verify-charges", action="store_true",
help="fetch each candidate charge to confirm captured is false")
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})
since = int(time.time()) - args.days * 86400
charges = {}
states = {}
all_money = {}
expired_money = {}
scanned = 0
for refund in page_refunds(s, since, args.max_refunds):
scanned += 1
currency = refund.get("currency") or "???"
add(all_money, currency, refund.get("amount"))
charge = None
if args.verify_charges and refund.get("reason") == EXPIRED:
charge_id = refund.get("charge")
if isinstance(charge_id, str):
if charge_id not in charges:
charges[charge_id] = get(s, "/charges/" + charge_id)
charge = charges[charge_id]
state, detail = classify(refund, charge)
states[state] = states.get(state, 0) + 1
if state in ("expired", "expired-unverified"):
add(expired_money, currency, refund.get("amount"))
log.warning("%-18s %s %s", state, refund.get("id", "?"), detail)
elif state == "inconsistent":
log.warning("%-18s %s %s", state, refund.get("id", "?"), detail)
if not scanned:
log.info("no refunds in the last %d day(s)", args.days)
return 0
log.info("%d refund(s) in %d day(s): %s", scanned, args.days,
", ".join("%d %s" % (n, k) for k, n in sorted(states.items())))
log.info("refunded in total: %s", money(all_money))
if not expired_money:
log.info("nothing refunded because an authorization expired")
return 0
log.warning("refunded because an authorization expired: %s", money(expired_money))
for currency, amount in sorted(expired_money.items()):
total = all_money.get(currency, 0)
if total:
log.warning(" %s: %.1f%% of everything refunded in this window",
currency.upper(), 100.0 * amount / total)
log.warning("repair: exclude reason=%s from the customer-facing refund rate "
"and report it as an operational number instead", EXPIRED)
log.warning("repair: fix the capture pipeline; the real deadline is "
"capture_before on the charge, not created plus seven days")
log.warning("repair: subscribe to charge.refund.updated and alert when a "
"refund arrives carrying this reason")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe refunds that Stripe wrote itself when an authorization expired.
*
* Read only. A paginated GET over Refunds and one lookup per candidate charge,
* no writes: give this a RESTRICTED key with read access to Refunds and Charges.
* The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// Set by Stripe when an uncaptured authorization runs out of time. The one
// reason value on the Refund object nobody in your business can choose.
export const EXPIRED = 'expired_uncaptured_charge';
const CUSTOMER_REASONS = ['requested_by_customer', 'duplicate', 'fraudulent'];
/**
* Sort one Refund into money a human gave back and money that fell off a card.
* Pure. `charge` is the Charge it belongs to, or null when it was not fetched.
*/
export function classify(refund, charge = null) {
const reason = refund.reason ?? null;
if (reason === EXPIRED) {
if (charge === null || charge === undefined) {
return ['expired-unverified',
'Stripe wrote this when the authorization expired, but the charge was ' +
'not fetched, so captured is unconfirmed'];
}
if (charge.captured === false) {
return ['expired',
'the authorization expired uncaptured: nobody issued this refund and ' +
'no customer asked for it'];
}
return ['inconsistent',
`reason says the authorization expired but the charge reports ` +
`captured=${JSON.stringify(charge.captured)}: read the charge before ` +
'counting this one'];
}
if (CUSTOMER_REASONS.includes(reason)) {
return ['customer', `a real refund (${reason}), belongs in the refund rate`];
}
if (reason === null) {
return ['unlabelled',
'no reason recorded: issued through the API or the Dashboard without one, ' +
'so it counts as a real refund until proven otherwise'];
}
return ['other', `unrecognised reason ${JSON.stringify(reason)}, left in the rate`];
}
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();
}
async function* pageRefunds(key, since, limit) {
let seen = 0;
const params = { limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/refunds', params);
const rows = page.data ?? [];
for (const refund of rows) { yield refund; seen += 1; }
if (!page.has_more || rows.length === 0 || seen >= limit) break;
params.starting_after = rows[rows.length - 1].id;
}
}
function add(bucket, currency, amount) {
bucket.set(currency, (bucket.get(currency) ?? 0) + (amount ?? 0));
}
function money(bucket) {
if (bucket.size === 0) return 'nothing';
return [...bucket.entries()].sort()
.map(([k, v]) => `${(v / 100).toFixed(2)} ${k.toUpperCase()}`).join(', ');
}
async function main() {
const days = Number(process.argv[2] ?? 90);
const verify = process.argv.includes('--verify-charges');
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 since = Math.floor(Date.now() / 1000) - days * 86400;
const charges = new Map();
const states = new Map();
const allMoney = new Map();
const expiredMoney = new Map();
let scanned = 0;
for await (const refund of pageRefunds(key, since, 2000)) {
scanned += 1;
const currency = refund.currency ?? '???';
add(allMoney, currency, refund.amount);
let charge = null;
if (verify && refund.reason === EXPIRED && typeof refund.charge === 'string') {
if (!charges.has(refund.charge)) {
charges.set(refund.charge, await get(key, `/charges/${refund.charge}`));
}
charge = charges.get(refund.charge);
}
const [state, detail] = classify(refund, charge);
states.set(state, (states.get(state) ?? 0) + 1);
if (state === 'expired' || state === 'expired-unverified') {
add(expiredMoney, currency, refund.amount);
console.warn(`${state.padEnd(18)} ${refund.id ?? '?'} ${detail}`);
} else if (state === 'inconsistent') {
console.warn(`${state.padEnd(18)} ${refund.id ?? '?'} ${detail}`);
}
}
if (scanned === 0) {
console.log(`no refunds in the last ${days} day(s)`);
return;
}
const summary = [...states.entries()].sort().map(([k, n]) => `${n} ${k}`).join(', ');
console.log(`${scanned} refund(s) in ${days} day(s): ${summary}`);
console.log(`refunded in total: ${money(allMoney)}`);
if (expiredMoney.size === 0) {
console.log('nothing refunded because an authorization expired');
return;
}
console.warn(`refunded because an authorization expired: ${money(expiredMoney)}`);
for (const [currency, amount] of [...expiredMoney.entries()].sort()) {
const total = allMoney.get(currency) ?? 0;
if (total) {
console.warn(` ${currency.toUpperCase()}: ` +
`${((100 * amount) / total).toFixed(1)}% of everything refunded in this window`);
}
}
console.warn(`repair: exclude reason=${EXPIRED} from the customer-facing refund ` +
'rate and report it as an operational number instead');
console.warn('repair: fix the capture pipeline; the real deadline is ' +
'capture_before on the charge, not created plus seven days');
console.warn('repair: subscribe to charge.refund.updated and alert when a ' +
'refund arrives carrying this reason');
process.exitCode = 1;
}
// Only run when invoked directly, so importing this module from the test file
// does not fire main(), fail on the missing key, and set a non-zero exit code
// that fails the whole test run 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
The case worth pinning is the expired refund whose charge was never fetched. It is almost certainly automatic and it is not yet proven, and a classifier that reports it as confirmed is the one that gets a finding thrown out when somebody opens the charge and sees something else. The inconsistent case is pinned for the same reason from the other direction.
from stripe_expired_capture_refunds import classify
def refund(reason="requested_by_customer"):
out = {"id": "re_1", "amount": 4900, "currency": "usd", "charge": "ch_1"}
if reason is not None:
out["reason"] = reason
return out
def test_expired_reason_with_an_uncaptured_charge_is_confirmed():
state, detail = classify(refund("expired_uncaptured_charge"), {"captured": False})
assert state == "expired"
assert "no customer asked" in detail
def test_expired_reason_without_the_charge_is_only_a_candidate():
# The whole point: unverified is not the same finding as verified.
state, detail = classify(refund("expired_uncaptured_charge"))
assert state == "expired-unverified"
assert "unconfirmed" in detail
def test_expired_reason_on_a_captured_charge_is_flagged_not_counted():
state, detail = classify(refund("expired_uncaptured_charge"), {"captured": True})
assert state == "inconsistent"
assert "captured=True" in detail
def test_a_customer_refund_stays_in_the_rate():
state, detail = classify(refund("requested_by_customer"), {"captured": True})
assert state == "customer"
assert "refund rate" in detail
def test_a_refund_with_no_reason_is_not_treated_as_expired():
assert classify(refund(None))[0] == "unlabelled"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-expired-capture-refunds.mjs';
function refund(reason = 'requested_by_customer') {
const out = { id: 're_1', amount: 4900, currency: 'usd', charge: 'ch_1' };
if (reason !== null) out.reason = reason;
return out;
}
test('expired reason with an uncaptured charge is confirmed', () => {
const [state, detail] = classify(refund('expired_uncaptured_charge'), { captured: false });
assert.equal(state, 'expired');
assert.match(detail, /no customer asked/);
});
test('expired reason without the charge is only a candidate', () => {
const [state, detail] = classify(refund('expired_uncaptured_charge'));
assert.equal(state, 'expired-unverified');
assert.match(detail, /unconfirmed/);
});
test('expired reason on a captured charge is flagged, not counted', () => {
const [state, detail] = classify(refund('expired_uncaptured_charge'), { captured: true });
assert.equal(state, 'inconsistent');
assert.match(detail, /captured=true/);
});
test('a customer refund stays in the rate', () => {
const [state, detail] = classify(refund('requested_by_customer'), { captured: true });
assert.equal(state, 'customer');
assert.match(detail, /refund rate/);
});
test('a refund with no reason is not treated as expired', () => {
assert.equal(classify(refund(null))[0], 'unlabelled');
});
FAQ
What does expired_uncaptured_charge actually mean?
That a payment was authorized but never captured, and the authorization reached the end of its window. Stripe records the released hold as a Refund and sets that reason itself. No API call of yours created it, and the customer was never charged in the first place, so nothing was really returned to them.
Should these count in my refund rate?
No. A refund rate is meant to measure customers changing their mind or being dissatisfied. These measure your capture pipeline missing a deadline. Keeping them in the same number means an engineering problem shows up as a product problem, and the team that can fix it never hears about it.
How do I prove a refund is one of these rather than a real one?
Fetch the charge it points at and read captured. A false there means no money was ever collected, which no genuine refund can be true of. That single field turns a suspicion about a reason string into something you can put in a ticket.
Is this the same check as watching for holds about to expire?
No, and you want both. Watching capture_before on the charge tells you which authorizations you are about to lose while you can still capture them. This one counts the ones already lost and shows what they did to your reported numbers. One is a work queue, the other is the bill.
Can I get alerted when one appears?
Yes. Refund events are delivered like anything else, so a handler on charge.refund.updated that checks the reason will page you the same day rather than at the next monthly review. It is a lagging signal either way, but days late beats a quarter late.
Related field notes
- Manual-capture holds expire before anyone captures them
- Refunds that failed or are stuck
- Intents sitting in requires_payment_method for weeks
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.
- The Refund object — Stripe API reference
- Place a hold on a payment method — Stripe Docs
- Refunds — Stripe Docs
- API keys — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.