Diagnostic Stripe
elevated-risk charges captured with no manual review
Disputes keep arriving for payments that went through weeks ago, and the chargeback rate is drifting toward the number the card networks care about. Open any one of them and Stripe already knew: the charge is marked elevated risk. Nobody was ever shown it, because nothing on the account was configured to show anybody anything.
Paginate GET /v1/charges?limit=100&created[gte]=<90 days ago> and keep the rows where outcome.risk_level is "elevated" and outcome.type is "authorized". Count how many of those have review: null and captured: true. Those went to the bank, were captured, and never passed a human.
Then compare disputed across that group against your normal-risk baseline. The gap between the two rates is the cost of the missing review rule, in your own numbers rather than in general advice. If not_assessed dominates instead, Radar never scored the traffic at all and no rule of any kind could have fired.
The problem in plain words
Radar's default rules block highest. They do nothing about elevated, and that is correct: elevated is the band where a meaningful share of the payments are genuine, so blocking it outright costs more in refused customers than it saves in fraud. The intended handling is a review queue, and a review queue only exists if somebody made one.
On an account where nobody did, the band behaves exactly like normal. The payment authorizes, captures, fulfils, and the risk score is written to a field that nothing in the integration reads. The Dashboard has a Reviews section, it is empty, and an empty queue looks identical whether it is empty because the traffic is clean or because it was never wired up.
The consequence lands six to eight weeks later as disputes, which is far enough away that it gets discussed as a fraud trend rather than as a configuration gap. Meanwhile every one of those disputes carries the fee, the lost goods, and a mark against the chargeback ratio that the networks measure you on.
Why it happens
There is nothing to switch off, which is why nobody notices. The failure is an absent rule. A missing rule has no row in any list, no toggle in the wrong position, and no audit entry. Every screen you would think to check looks the way a healthy account looks.
The score is written after the decision, on an object nobody reads. outcome.risk_level lands on the Charge. Order pipelines read the PaymentIntent status and move on; the charge is usually fetched only for receipts and refunds. The information was there the whole time, one field away from the code that shipped the goods.
Review is a rule action, not a setting. Placing a payment in review means writing a Radar rule that says so — typically :risk_level: = 'elevated', often narrowed by amount so the queue stays a size a human can actually work. Until that rule exists, charge.review is null on every payment, and null is the value the field has on a healthy account too.
not_assessed is a different failure wearing the same clothes. If Radar never received a session from the client — Stripe.js not mounted on the payment page, or a server-side confirm without radar_options[session] — charges come back not_assessed. No score means no rule can match, so adding a review rule to that account changes nothing at all. Check this before you tune anything.
The evidence is separated from the cause by weeks. Disputes arrive long after the payment, attributed to whoever is looking at fraud that month. Joining disputed back to outcome.risk_level on the same charges is what converts a vague sense that fraud is up into a specific, fixable gap.
The fix, as a flow
The script reads the risk score Radar wrote on every charge and asks what happened next, because the score and the outcome are on the same object and nothing in an order pipeline ever looks at either.
How to fix it
Scan 90 days, not 30
Disputes lag the payment by weeks. A 30-day window shows you the elevated charges going through with the consequence still in the post, which reads as a much smaller problem than it is.
Check for not_assessed first
If a large share of charges come back not_assessed, stop. Radar never scored them, so there is no risk band to act on and a review rule would sit there matching nothing. Get sessions flowing from the client, then come back.
Separate elevated charges that Radar already stopped
An outcome.type other than authorized means a rule or the issuer already dealt with it. Those are not the finding. The finding is the ones that were authorized, captured, and never seen.
Treat uncaptured elevated charges as their own bucket
An authorized but uncaptured elevated charge is a hold that can still be released, which is a different instruction to a human than one where the money already moved and the goods already shipped. It is also on a clock, so it belongs at the top of the output.
Compute the dispute rate for the group and compare it to normal
This is the number that gets the rule written. If elevated charges dispute at several times the normal rate, the review queue pays for itself in one line of arithmetic. The repair is a Radar rule placing :risk_level: = 'elevated' in review, scoped by amount if the volume needs it, and somebody whose job is to work the queue daily.
How to check it worked
Re-run over a window that begins after the rule was added. Elevated charges should be arriving in the review queue rather than in the captured-and-unseen bucket.
python3 stripe_elevated_risk_review.py --days 90
# 3,104 charge(s): 0 elevated captured unreviewed, 41 reviewed, 0 not_assessed
The full code
One paginated GET over Charges and no writes — a restricted key with read access to Charges is enough. The classifier is pure and reads five fields off one charge, because the distinctions that matter here are all made between charges that look identical from the order pipeline: scored or not scored, stopped or authorized, reviewed or not, captured or still a hold, disputed or not yet.
"""Report Stripe charges scored elevated risk that were captured with no review.
Read only. One paginated GET, no writes: give this a RESTRICTED key with read
access to 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_elevated_risk_review")
API = "https://api.stripe.com/v1"
def verdict(charge):
"""Classify one Charge by what happened to an elevated risk score. Pure.
Radar's default rules block `highest` and leave `elevated` alone, so an
elevated charge is authorized and captured unless a review rule puts it in
front of a human. `review` is null both when no such rule exists and on a
perfectly healthy account, which is why the surrounding fields decide the
verdict rather than that one on its own.
Returns (state, detail).
"""
outcome = charge.get("outcome") or {}
risk = outcome.get("risk_level")
if risk in (None, "not_assessed"):
return ("not_assessed",
"Radar never scored this charge: no Radar session reached the API, "
"so no rule of any kind could have matched it")
if risk != "elevated":
return ("baseline", "risk_level %s, outside the scope of this check" % risk)
if outcome.get("type") != "authorized":
return ("stopped",
"elevated and outcome.type %r: something already stopped it"
% (outcome.get("type"),))
if charge.get("review"):
return ("reviewed", "elevated and placed in the manual review queue")
if not charge.get("captured"):
return ("uncaptured",
"elevated and unreviewed, authorized but not captured: this is "
"still a hold and it can be released rather than taken")
if charge.get("disputed"):
return ("disputed",
"elevated, captured with no review in front of it, and already "
"disputed: this one is the bill for the missing rule")
return ("straight-through",
"elevated risk, authorized, captured, and no human ever saw it")
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_charges(session, since, limit):
seen = 0
params = {"limit": 100, "created[gte]": since}
while True:
page = get(session, "/charges", **params)
rows = page.get("data", [])
for charge in rows:
yield charge
seen += 1
if not page.get("has_more") or not rows or seen >= limit:
break
params["starting_after"] = rows[-1]["id"]
def rate(disputed, total):
return (100.0 * disputed / total) if total else 0.0
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=90,
help="how far back to scan charges (default 90)")
ap.add_argument("--max-charges", type=int, default=5000,
help="stop paginating after this many charges")
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
states = {}
scanned = 0
unreviewed_amount = {}
normal_total = normal_disputed = 0
elevated_total = elevated_disputed = 0
for charge in page_charges(s, since, args.max_charges):
scanned += 1
state, detail = verdict(charge)
states[state] = states.get(state, 0) + 1
risk = (charge.get("outcome") or {}).get("risk_level")
if risk == "normal" and charge.get("captured"):
normal_total += 1
normal_disputed += 1 if charge.get("disputed") else 0
elif risk == "elevated" and charge.get("captured"):
elevated_total += 1
elevated_disputed += 1 if charge.get("disputed") else 0
if state in ("straight-through", "disputed", "uncaptured"):
currency = charge.get("currency") or "???"
unreviewed_amount[currency] = (unreviewed_amount.get(currency, 0)
+ (charge.get("amount") or 0))
log.warning("%-16s %s %s", state, charge.get("id", "?"), detail)
if not scanned:
log.info("no charges in the last %d day(s)", args.days)
return 0
log.info("%d charge(s) in %d day(s): %s", scanned, args.days,
", ".join("%d %s" % (n, k) for k, n in sorted(states.items())))
not_assessed = states.get("not_assessed", 0)
if not_assessed > scanned / 2:
log.warning("%d of %d charges are not_assessed: Radar is not scoring this "
"traffic, so fix that before adding any rule", not_assessed, scanned)
log.warning("repair: mount Stripe.js on the payment page, or pass "
"radar_options[session] on server-side confirms")
return 1
leaked = states.get("straight-through", 0) + states.get("disputed", 0)
if not leaked and not states.get("uncaptured", 0):
log.info("no elevated-risk charge was captured without a review")
return 0
for currency, amount in sorted(unreviewed_amount.items()):
log.warning("elevated and unreviewed: %.2f %s", amount / 100.0, currency.upper())
log.warning("dispute rate: elevated %.2f%% (%d/%d) vs normal %.2f%% (%d/%d)",
rate(elevated_disputed, elevated_total), elevated_disputed, elevated_total,
rate(normal_disputed, normal_total), normal_disputed, normal_total)
log.warning("repair: Dashboard, Radar, Rules: add \"Place in review if "
":risk_level: = 'elevated'\", scoped by amount if the queue is "
"too large to work daily")
log.warning("repair: give the review queue an owner; a queue nobody works "
"expires its own payments and is worse than no queue")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe charges scored elevated risk that were captured with no review.
*
* Read only. One paginated GET, no writes: give this a RESTRICTED key with read
* access to Charges. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Classify one Charge by what happened to an elevated risk score. Pure.
*
* Radar's defaults block `highest` and leave `elevated` alone, so an elevated
* charge is authorized and captured unless a review rule puts it in front of a
* human. `review` is null both when no such rule exists and on a healthy
* account, so the surrounding fields decide the verdict rather than that one.
*/
export function verdict(charge) {
const outcome = charge.outcome ?? {};
const risk = outcome.risk_level ?? null;
if (risk === null || risk === 'not_assessed') {
return ['not_assessed',
'Radar never scored this charge: no Radar session reached the API, so no ' +
'rule of any kind could have matched it'];
}
if (risk !== 'elevated') {
return ['baseline', `risk_level ${risk}, outside the scope of this check`];
}
if (outcome.type !== 'authorized') {
return ['stopped',
`elevated and outcome.type ${JSON.stringify(outcome.type)}: something ` +
'already stopped it'];
}
if (charge.review) {
return ['reviewed', 'elevated and placed in the manual review queue'];
}
if (!charge.captured) {
return ['uncaptured',
'elevated and unreviewed, authorized but not captured: this is still a ' +
'hold and it can be released rather than taken'];
}
if (charge.disputed) {
return ['disputed',
'elevated, captured with no review in front of it, and already disputed: ' +
'this one is the bill for the missing rule'];
}
return ['straight-through',
'elevated risk, authorized, captured, and no human ever saw it'];
}
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* pageCharges(key, since, limit) {
let seen = 0;
const params = { limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/charges', params);
const rows = page.data ?? [];
for (const charge of rows) { yield charge; seen += 1; }
if (!page.has_more || rows.length === 0 || seen >= limit) break;
params.starting_after = rows[rows.length - 1].id;
}
}
const rate = (disputed, total) => (total ? (100 * disputed) / total : 0);
async function main() {
const days = Number(process.argv[2] ?? 90);
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 states = new Map();
const unreviewed = new Map();
let scanned = 0;
let normalTotal = 0; let normalDisputed = 0;
let elevatedTotal = 0; let elevatedDisputed = 0;
for await (const charge of pageCharges(key, since, 5000)) {
scanned += 1;
const [state, detail] = verdict(charge);
states.set(state, (states.get(state) ?? 0) + 1);
const risk = (charge.outcome ?? {}).risk_level;
if (risk === 'normal' && charge.captured) {
normalTotal += 1;
if (charge.disputed) normalDisputed += 1;
} else if (risk === 'elevated' && charge.captured) {
elevatedTotal += 1;
if (charge.disputed) elevatedDisputed += 1;
}
if (state === 'straight-through' || state === 'disputed' || state === 'uncaptured') {
const currency = charge.currency ?? '???';
unreviewed.set(currency, (unreviewed.get(currency) ?? 0) + (charge.amount ?? 0));
console.warn(`${state.padEnd(16)} ${charge.id ?? '?'} ${detail}`);
}
}
if (scanned === 0) {
console.log(`no charges in the last ${days} day(s)`);
return;
}
const summary = [...states.entries()].sort().map(([k, n]) => `${n} ${k}`).join(', ');
console.log(`${scanned} charge(s) in ${days} day(s): ${summary}`);
const notAssessed = states.get('not_assessed') ?? 0;
if (notAssessed > scanned / 2) {
console.warn(`${notAssessed} of ${scanned} charges are not_assessed: Radar is ` +
'not scoring this traffic, so fix that before adding any rule');
console.warn('repair: mount Stripe.js on the payment page, or pass ' +
'radar_options[session] on server-side confirms');
process.exitCode = 1;
return;
}
const leaked = (states.get('straight-through') ?? 0) + (states.get('disputed') ?? 0);
if (!leaked && !(states.get('uncaptured') ?? 0)) {
console.log('no elevated-risk charge was captured without a review');
return;
}
for (const [currency, amount] of [...unreviewed.entries()].sort()) {
console.warn(`elevated and unreviewed: ${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`);
}
console.warn(
`dispute rate: elevated ${rate(elevatedDisputed, elevatedTotal).toFixed(2)}% ` +
`(${elevatedDisputed}/${elevatedTotal}) vs normal ` +
`${rate(normalDisputed, normalTotal).toFixed(2)}% (${normalDisputed}/${normalTotal})`);
console.warn("repair: Dashboard, Radar, Rules: add \"Place in review if " +
":risk_level: = 'elevated'\", scoped by amount if the queue is too large to work daily");
console.warn('repair: give the review queue an owner; a queue nobody works ' +
'expires its own payments and is worse than no queue');
process.exitCode = 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The cases worth pinning are the ones that look alike from the order pipeline. An elevated charge Radar already stopped, one a human reviewed, and one that sailed through all read as the same score on the same object, and only the surrounding fields tell them apart. The not_assessed case is pinned separately because it is the one where adding the rule everybody reaches for would change nothing.
from stripe_elevated_risk_review import verdict
def charge(risk="elevated", outcome_type="authorized", review=None,
captured=True, disputed=False):
return {
"id": "ch_1",
"amount": 12900,
"currency": "usd",
"captured": captured,
"disputed": disputed,
"review": review,
"outcome": {"risk_level": risk, "type": outcome_type},
}
def test_elevated_captured_with_no_review_is_the_finding():
state, detail = verdict(charge())
assert state == "straight-through"
assert "no human" in detail
def test_elevated_that_reached_review_is_not_flagged():
assert verdict(charge(review="prv_1"))[0] == "reviewed"
def test_elevated_still_on_a_hold_is_its_own_state():
# Different instruction to a human: this one can still be released.
state, detail = verdict(charge(captured=False))
assert state == "uncaptured"
assert "released" in detail
def test_elevated_already_disputed_is_separated_from_the_rest():
assert verdict(charge(disputed=True))[0] == "disputed"
def test_not_assessed_is_not_reported_as_clean():
state, detail = verdict(charge(risk="not_assessed"))
assert state == "not_assessed"
assert "never scored" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-elevated-risk-review.mjs';
function charge({ risk = 'elevated', type = 'authorized', review = null,
captured = true, disputed = false } = {}) {
return {
id: 'ch_1',
amount: 12900,
currency: 'usd',
captured,
disputed,
review,
outcome: { risk_level: risk, type },
};
}
test('elevated captured with no review is the finding', () => {
const [state, detail] = verdict(charge());
assert.equal(state, 'straight-through');
assert.match(detail, /no human/);
});
test('elevated that reached review is not flagged', () => {
assert.equal(verdict(charge({ review: 'prv_1' }))[0], 'reviewed');
});
test('elevated still on a hold is its own state', () => {
const [state, detail] = verdict(charge({ captured: false }));
assert.equal(state, 'uncaptured');
assert.match(detail, /released/);
});
test('elevated already disputed is separated from the rest', () => {
assert.equal(verdict(charge({ disputed: true }))[0], 'disputed');
});
test('not_assessed is not reported as clean', () => {
const [state, detail] = verdict(charge({ risk: 'not_assessed' }));
assert.equal(state, 'not_assessed');
assert.match(detail, /never scored/);
});
FAQ
Why does Stripe capture elevated-risk payments by default?
Because a large share of them are genuine. Radar's default rules block the highest band, where the evidence is strong enough that refusing is cheaper than accepting. Elevated is the band where a blanket block would cost more in turned-away customers than it saves, which is why the intended handling is a review queue rather than a rule that refuses.
Is this the same as highest-risk charges getting through?
No. A highest-risk charge succeeding means something overrode a rule that exists and is enabled, usually an allow rule sitting above it. An elevated-risk charge succeeding means no rule ever applied to it, because none was written. One is an override to find, the other is an absence to fill.
What if most of my charges come back not_assessed?
Then Radar is not scoring your traffic and no review rule will help. It means no Radar session reached the API: Stripe.js is not mounted on the payment page, or a server-side confirm is not passing radar_options[session]. Fix that first, wait for a fresh window of scored charges, then look at risk levels.
How large will the review queue be?
Larger than you expect, which is why the rule usually needs scoping. Adding an amount condition so only elevated payments above a threshold go to review keeps the queue at a size a person can actually work. A queue nobody works is worse than none, because payments sitting in review can expire while they wait.
Does this script need a live secret key?
No. A restricted key with read access to Charges covers every call it makes. It never approves, refuses, refunds, or captures anything, so a leak exposes the shape of your payment volume and nothing else.
Related field notes
- Highest-risk charges succeed instead of being blocked
- Radar blocks payments and nobody reads the reasons
- Disputes lost without a response
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.
- Reviewing payments — Stripe Docs
- Radar rules — Stripe Docs
- The Charge object — Stripe API reference
- Declines — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.