Diagnostic Stripe
radar reviews sit open for days while funds stay at risk
Somebody added a review rule, which was the right instinct. The queue it feeds has forty-one items in it, the oldest is from three weeks ago, and nobody has opened the page since the week it was set up. Every one of those payments was already taken, and the ones that were not have quietly stopped being collectable.
Paginate GET /v1/reviews and look at open. Anything with open == true is a payment Stripe is waiting on you to judge. Take the age from created: past 3 days the queue is not being worked, and past 7 days any charge that was authorized but not captured has had its hold released and cannot be captured at all.
Then audit the rule that fills the queue. Over 90 days, divide the reviews closed with closed_reason == "approved" by all the closed ones. A ratio near 1.0 means the rule is flagging traffic you always accept, and the queue is pure cost.
The problem in plain words
An unworked review queue is worse than no review rule, and that is the part people get wrong. With no rule the payment goes through and you take the fraud risk knowingly. With a rule and no one working it, the payment also goes through — a review does not hold anything back on a normal automatic-capture integration — and you have added a queue that generates guilt rather than decisions.
On separate authorization and capture it is materially worse. There the review really is blocking, because you were going to capture manually and now nobody has. Seven days later the authorization is released by the issuer. The order was picked, packed, possibly shipped, and the charge you were relying on no longer exists to capture.
Why it happens
The queue lives somewhere nobody has a reason to visit. Payments show up in reporting, disputes arrive by email, failed payouts eventually make somebody call. A review sits on a Dashboard page that has no notification attached to it unless you subscribed to review.opened, and almost nobody did.
The rule that fills it was written to be safe, which means broad. Stripe's own example of a rule to narrow is if :card_funding: = 'prepaid', tightened to if :is_disposable_email: and :card_funding: = 'prepaid'. The broad version flags a large, mostly legitimate slice of traffic. Every one of those needs a human, and every one of them gets approved, until the humans stop coming.
Approving everything looks like diligence and is indistinguishable from doing nothing. If the last two hundred reviews were all approved, the rule has never once changed an outcome. That is a measurable fact sitting in closed_reason, and it is the number that tells you to delete the rule rather than staff the queue.
The seven-day capture deadline is invisible from the queue itself. The review object does not say the authorization has lapsed. You have to read captured on the charge behind it, and by then it is a fact rather than a warning.
The fix, as a flow
The script ages every open review against the two deadlines that matter, then reads the charge behind it, because a review on an uncaptured authorization stops being actionable at seven days.
How to fix it
Page the reviews and split on open
GET /v1/reviews returns closed reviews too, which is useful: the closed ones are the evidence about the rule and the open ones are the work. Filter client-side on open rather than assuming a filter parameter exists.
Age every open review from created
Three days is when a queue stops being a queue and becomes a backlog; Stripe's own guidance is to work reviews as soon as possible. Seven days is not a judgement call, it is the point at which an uncaptured authorization is gone.
Read captured on the charge behind each open review
This is the field that separates "we still owe someone a decision" from "we can no longer act on this at all". GET /v1/charges/{id} for the id in review.charge. Cache it; several reviews can point at the same charge after a retry.
Group the open ones by opened_reason
rule means one of your Radar rules put it there and the rule is a candidate for narrowing. manual means a person did, which is a different conversation. A queue that is 90% rule has a configuration problem, not a staffing problem.
Compute the approval rate before you decide to staff the queue
Over 90 days, closed_reason == "approved" against every closed review. Near 1.0 and the honest fix is to delete or narrow the rule in Dashboard, Radar, then Rules. Below that, the rule is earning its place and the queue needs an owner and an alert on review.opened.
How to check it worked
Re-run after the queue is worked. Nothing open past three days, and the approval rate should have a verdict you are willing to defend.
python3 stripe_radar_review_queue.py
# 0 open review(s) past 3 days
# earning 41% approved: the rule is catching real fraud
The full code
Two read endpoints and no writes — a restricted key with read access to Reviews and Charges is enough. Two pure functions carry the judgement: one ages a single open review against the two deadlines that matter, and one turns ninety days of closed_reason values into a verdict on the rule itself.
"""Report Stripe Radar reviews left open while the funds behind them are at risk.
Read only. Paginated GETs and nothing else: give this a RESTRICTED key with read
access to Reviews 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_radar_review_queue")
API = "https://api.stripe.com/v1"
STALE_DAYS = 3 # past this the queue is a backlog, not a queue
LAPSE_DAYS = 7 # an uncaptured authorization is released at this age
MIN_CLOSED = 20 # below this the approval rate is noise
OVERBROAD = 0.95 # approvals at or above this: the rule never changes an outcome
WIDE = 0.80
def verdict(age_days, captured):
"""Classify one open review. Pure, so both deadlines can be tested offline.
`captured` is the `captured` flag of the review's charge, or None when the
charge could not be read. Returns (state, detail).
"""
if age_days < STALE_DAYS:
return ("open",
"open for %.1f day(s), still inside the window Stripe asks you to "
"work" % age_days)
if captured is False and age_days >= LAPSE_DAYS:
return ("lapsed",
"open for %.1f day(s) on an uncaptured authorization: the hold was "
"released at %d days and approving it now captures nothing"
% (age_days, LAPSE_DAYS))
if captured is False:
return ("expiring",
"open for %.1f day(s) on an uncaptured authorization, released in "
"%.1f day(s)" % (age_days, LAPSE_DAYS - age_days))
if age_days >= LAPSE_DAYS:
return ("critical",
"open for %.1f day(s) on a captured charge: the money is with you "
"and the dispute window is already running" % age_days)
return ("stale", "open for %.1f day(s) on a captured charge" % age_days)
def rule_health(approved, closed):
"""Judge the review rule from how its reviews were closed. Pure.
`approved` counts closed_reason == "approved"; `closed` counts every review
with any closed_reason. Returns (state, detail).
"""
if closed < MIN_CLOSED:
return ("insufficient",
"%d closed review(s) is too few to judge the rule" % closed)
rate = approved / float(closed)
if rate >= OVERBROAD:
return ("overbroad",
"%.0f%% of closed reviews were approved: the rule flags traffic you "
"always accept and has never changed an outcome" % (rate * 100))
if rate >= WIDE:
return ("wide",
"%.0f%% approved: add a second predicate before staffing this queue"
% (rate * 100))
return ("earning", "%.0f%% approved: the rule is catching real fraud"
% (rate * 100))
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(session, path, cap, **params):
"""Collect up to `cap` objects from a list endpoint."""
out = []
params = dict(params)
params["limit"] = 100
while True:
p = get(session, path, **params)
data = p.get("data", [])
out.extend(data)
if not data or not p.get("has_more") or len(out) >= cap:
break
params["starting_after"] = data[-1]["id"]
return out
def captured_flag(session, charge_id, cache):
"""Read `captured` off the charge behind a review. None when unreadable."""
if not charge_id:
return None
if charge_id not in cache:
try:
cache[charge_id] = get(session, "/charges/" + charge_id).get("captured")
except requests.HTTPError:
cache[charge_id] = None
return cache[charge_id]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=90,
help="window for the approval-rate audit")
ap.add_argument("--max-reviews", type=int, default=2000,
help="stop paginating after this many reviews")
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 = time.time()
since = now - args.days * 86400
reviews = page(s, "/reviews", args.max_reviews)
cache = {}
approved = closed = flagged = 0
by_reason = {}
for rev in reviews:
created = rev.get("created", now)
if created >= since and rev.get("closed_reason"):
closed += 1
if rev["closed_reason"] == "approved":
approved += 1
if not rev.get("open"):
continue
reason = rev.get("opened_reason") or "unknown"
by_reason[reason] = by_reason.get(reason, 0) + 1
age = (now - created) / 86400.0
state, detail = verdict(age, captured_flag(s, rev.get("charge"), cache))
if state == "open":
log.info("%-9s %s %s", state, rev.get("id"), detail)
continue
flagged += 1
log.warning("%-9s %s %s", state, rev.get("id"), detail)
log.warning(" opened by %s, charge %s", reason, rev.get("charge"))
for reason, n in sorted(by_reason.items()):
log.info("%d open review(s) opened by %s", n, reason)
health, detail = rule_health(approved, closed)
log.info("%-12s %s", health, detail)
if not flagged and health not in ("overbroad", "wide"):
log.info("0 open review(s) past %d days", STALE_DAYS)
return 0
if flagged:
log.warning(" work the queue: Dashboard, Radar, Reviews, then Approve, "
"Refund, or Refund and report fraud on each one")
log.warning(" alert instead of polling: subscribe an endpoint to "
"review.opened")
if health in ("overbroad", "wide"):
log.warning(" narrow the rule in Dashboard, Radar, Rules: add a second "
"predicate, for example is_disposable_email alongside the "
"card_funding test, or delete the rule outright")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe Radar reviews left open while the funds behind them are at risk.
*
* Read only. Paginated GETs and nothing else: give this a RESTRICTED key with
* read access to Reviews and Charges. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
export const STALE_DAYS = 3; // past this the queue is a backlog, not a queue
export const LAPSE_DAYS = 7; // an uncaptured authorization is released at this age
const MIN_CLOSED = 20;
const OVERBROAD = 0.95;
const WIDE = 0.8;
/**
* Classify one open review. Pure, so both deadlines can be tested offline.
* `captured` is the charge's captured flag, or null when it could not be read.
*/
export function verdict(ageDays, captured) {
if (ageDays < STALE_DAYS) {
return ['open',
`open for ${ageDays.toFixed(1)} day(s), still inside the window Stripe asks you to work`];
}
if (captured === false && ageDays >= LAPSE_DAYS) {
return ['lapsed',
`open for ${ageDays.toFixed(1)} day(s) on an uncaptured authorization: the hold ` +
`was released at ${LAPSE_DAYS} days and approving it now captures nothing`];
}
if (captured === false) {
return ['expiring',
`open for ${ageDays.toFixed(1)} day(s) on an uncaptured authorization, released ` +
`in ${(LAPSE_DAYS - ageDays).toFixed(1)} day(s)`];
}
if (ageDays >= LAPSE_DAYS) {
return ['critical',
`open for ${ageDays.toFixed(1)} day(s) on a captured charge: the money is with ` +
'you and the dispute window is already running'];
}
return ['stale', `open for ${ageDays.toFixed(1)} day(s) on a captured charge`];
}
/** Judge the review rule from how its reviews were closed. Pure. */
export function ruleHealth(approved, closed) {
if (closed < MIN_CLOSED) {
return ['insufficient', `${closed} closed review(s) is too few to judge the rule`];
}
const rate = approved / closed;
const pct = Math.round(rate * 100);
if (rate >= OVERBROAD) {
return ['overbroad',
`${pct}% of closed reviews were approved: the rule flags traffic you always ` +
'accept and has never changed an outcome'];
}
if (rate >= WIDE) {
return ['wide', `${pct}% approved: add a second predicate before staffing this queue`];
}
return ['earning', `${pct}% approved: the rule is catching real fraud`];
}
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 page(key, path, cap, params = {}) {
const out = [];
const q = { ...params, limit: 100 };
for (;;) {
const p = await get(key, path, q);
const data = p.data ?? [];
out.push(...data);
if (data.length === 0 || !p.has_more || out.length >= cap) break;
q.starting_after = data[data.length - 1].id;
}
return out;
}
async function capturedFlag(key, chargeId, cache) {
if (!chargeId) return null;
if (!(chargeId in cache)) {
try {
cache[chargeId] = (await get(key, `/charges/${chargeId}`)).captured ?? null;
} catch {
cache[chargeId] = null;
}
}
return cache[chargeId];
}
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 days = Number(process.env.DAYS ?? 90);
const now = Date.now() / 1000;
const since = now - days * 86400;
const reviews = await page(key, '/reviews', 2000);
const cache = {};
const byReason = {};
let approved = 0;
let closed = 0;
let flagged = 0;
for (const rev of reviews) {
const created = rev.created ?? now;
if (created >= since && rev.closed_reason) {
closed += 1;
if (rev.closed_reason === 'approved') approved += 1;
}
if (!rev.open) continue;
const reason = rev.opened_reason ?? 'unknown';
byReason[reason] = (byReason[reason] ?? 0) + 1;
const age = (now - created) / 86400;
const [state, detail] = verdict(age, await capturedFlag(key, rev.charge, cache));
if (state === 'open') {
console.log(`${state.padEnd(9)} ${rev.id} ${detail}`);
continue;
}
flagged += 1;
console.warn(`${state.padEnd(9)} ${rev.id} ${detail}`);
console.warn(` opened by ${reason}, charge ${rev.charge}`);
}
for (const [reason, n] of Object.entries(byReason).sort()) {
console.log(`${n} open review(s) opened by ${reason}`);
}
const [health, detail] = ruleHealth(approved, closed);
console.log(`${health.padEnd(12)} ${detail}`);
if (!flagged && health !== 'overbroad' && health !== 'wide') {
console.log(`0 open review(s) past ${STALE_DAYS} days`);
return;
}
if (flagged) {
console.warn(' work the queue: Dashboard, Radar, Reviews, then Approve, Refund, ' +
'or Refund and report fraud on each one');
console.warn(' alert instead of polling: subscribe an endpoint to review.opened');
}
if (health === 'overbroad' || health === 'wide') {
console.warn(' narrow the rule in Dashboard, Radar, Rules: add a second predicate, ' +
'for example is_disposable_email alongside the card_funding test, or ' +
'delete the rule outright');
}
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
The tests are about the two deadlines and the one ratio. Day three is where a queue becomes a backlog and day seven is where an uncaptured authorization stops existing, so a classifier that is a day out on either turns a warning into a post-mortem. The approval-rate tests exist because 95% and 80% are the numbers that decide whether the answer is to hire someone or to delete a rule.
from stripe_radar_review_queue import rule_health, verdict
def test_fresh_review_is_just_open():
state, detail = verdict(1.0, True)
assert state == "open"
assert "still inside the window" in detail
def test_three_days_is_the_stale_boundary():
# Exactly three must already flag. A check that waits until day four has
# spent nearly half the capture window before saying anything.
assert verdict(2.9, True)[0] == "open"
assert verdict(3.0, True)[0] == "stale"
def test_uncaptured_hold_expires_at_seven_days():
state, detail = verdict(6.9, False)
assert state == "expiring"
assert "0.1 day(s)" in detail
assert verdict(7.0, False)[0] == "lapsed"
def test_captured_charge_past_seven_days_is_critical():
state, detail = verdict(9.0, True)
assert state == "critical"
assert "dispute window" in detail
def test_approval_rate_needs_a_sample_before_it_judges_the_rule():
assert rule_health(19, 19)[0] == "insufficient"
assert rule_health(20, 20)[0] == "overbroad"
assert rule_health(16, 20)[0] == "wide"
assert rule_health(8, 20)[0] == "earning"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ruleHealth, verdict } from './stripe-radar-review-queue.mjs';
test('fresh review is just open', () => {
const [state, detail] = verdict(1.0, true);
assert.equal(state, 'open');
assert.ok(detail.includes('still inside the window'));
});
test('three days is the stale boundary', () => {
assert.equal(verdict(2.9, true)[0], 'open');
assert.equal(verdict(3.0, true)[0], 'stale');
});
test('uncaptured hold expires at seven days', () => {
const [state, detail] = verdict(6.9, false);
assert.equal(state, 'expiring');
assert.ok(detail.includes('0.1 day(s)'));
assert.equal(verdict(7.0, false)[0], 'lapsed');
});
test('captured charge past seven days is critical', () => {
const [state, detail] = verdict(9.0, true);
assert.equal(state, 'critical');
assert.ok(detail.includes('dispute window'));
});
test('approval rate needs a sample before it judges the rule', () => {
assert.equal(ruleHealth(19, 19)[0], 'insufficient');
assert.equal(ruleHealth(20, 20)[0], 'overbroad');
assert.equal(ruleHealth(16, 20)[0], 'wide');
assert.equal(ruleHealth(8, 20)[0], 'earning');
});
FAQ
Does a review hold the payment while it is open?
Not on a normal automatic-capture integration. The charge has already been made and the goods can already ship; open just means Stripe is waiting for your judgement so it can learn from it and so you can refund quickly if the answer is fraud. On separate authorization and capture it genuinely blocks, because the capture is the thing you have not done.
What actually happens at seven days?
An uncaptured authorization is released. That is a card network limit, not a Stripe setting, and after it passes there is nothing to capture. The review can still be closed, but closing it recovers no money. This is why the script reads captured on the charge rather than judging the review by its age alone.
Can I filter GET /v1/reviews to only the open ones?
Filter client-side on the open field. Pulling everything is what makes the second half of this check possible anyway: the closed reviews and their closed_reason values are the only evidence you have about whether the rule filling the queue is worth keeping.
An approval rate near 100% means the rule works, surely?
It means the rule has never changed an outcome. Every payment it flagged, you accepted. That is a rule generating work and latency and buying nothing. Narrow it with a second predicate so it flags a smaller, genuinely ambiguous slice, or delete it and take the risk knowingly.
How do I stop polling for this?
Subscribe an endpoint to review.opened and route it somewhere a person reads. The script is still worth running daily, because a notification tells you a review arrived and only the queue tells you one is about to lapse.
Related field notes
- Radar blocks payments and nobody reads the block reasons
- Highest-risk charges succeed instead of being blocked
- requires_action intents pile up at the 3DS step
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 review object — Stripe API reference
- Reviews — Stripe Docs
- Rules — Stripe Docs
- Place a hold on a payment method — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.