Diagnostic Stripe
radar blocks payments and nobody reads the block reasons
Support keeps hearing the same sentence: my card works everywhere else. The charge shows as failed with a message that says nothing, the customer's bank has no record of the attempt at all, and nobody on your side can say why. The payment never left Stripe.
Paginate GET /v1/charges and filter on outcome.type == "blocked". Those charges carry outcome.network_status of not_sent_to_network, which is the literal statement that the issuer never saw them.
Group by outcome.reason. rule means one of your own Radar rules fired and is the reason you should look at first; highest_risk_level is Radar's built-in threshold; low_probability_of_authorization is Adaptive Acceptance declining to spend a network fee on a charge it expects to fail, and is not fraud at all.
The problem in plain words
A blocked charge is not a decline, and the difference is the whole problem. A decline came back from the issuer with a code you can read, argue with, and retry against. A block happened before authorization, so there is no issuer response, no decline code, and nothing the customer's bank can tell them when they call to ask.
What the customer sees is a generic failure. What your logs show is a failed charge. What your fraud reporting shows, if it counts blocked charges as fraud attempts, is a healthy-looking prevention rate. The one field that says what actually happened is outcome.reason, and almost no integration reads it, because reading it requires knowing the object has an outcome at all.
Why it happens
Custom rules outlive the pattern they were written for. Somebody blocked a country during a fraud wave in 2021, or a BIN range, or every charge over a threshold. The wave passed; the rule did not. It keeps firing against ordinary customers and there is nothing that expires it or reports on it.
The block threshold is a business decision made once. highest_risk_level blocks are Radar's default, and they are usually right. But the boundary between blocking and reviewing is a choice, and an account that never configured a review queue is blocking payments it could have looked at instead.
Adaptive Acceptance blocks look identical and are not the same thing. low_probability_of_authorization means Stripe predicted the issuer would decline and skipped the attempt to avoid the fee. Counting those as fraud inflates your block rate and sends you to change rules that were never involved.
Nothing sums the cost. The individual charge is a line in a list. The total value of what a single rule blocked last month is a number nobody has ever calculated, and it is usually the number that ends the argument.
The fix, as a flow
The script reads the outcome on every charge and keeps only the blocked ones, then groups them by reason and sums the amounts, because the cost of a rule is the number that settles what to do about it.
How to fix it
Pull the last 30 days of charges and filter on outcome.type
Only blocked matters here. issuer_declined is a different investigation and mixing the two makes both harder, because one has a decline code to work with and the other never will.
Group by outcome.reason and sum the amounts
Counts tell you what is firing; summed amount tells you what it costs. A rule that blocks a hundred small charges and a rule that blocks four large ones are different problems and the count alone hides that.
Read outcome.seller_message on a sample
This is Stripe's own human-readable sentence about why the charge was stopped, written for you rather than for the customer. It is the fastest way to tell a rule you wrote from a threshold Stripe applied.
Separate Adaptive Acceptance out of the total
low_probability_of_authorization is working as intended: the charge was very likely to be declined and Stripe saved you the network fee. Leave it alone, and take it out of the fraud numbers, or every review of those numbers starts with the same wrong assumption.
Narrow the rule rather than deleting it
In the Dashboard, Radar then Rules, find the rule that outcome.reason pointed at. Scoping it to an amount band or a specific BIN usually keeps whatever protection it still provides while returning the traffic it should never have touched. For highest_risk_level, add a review threshold before you move the block threshold.
How to check it worked
Re-run the script over a window that starts after the rule change. The reason you narrowed should either disappear or drop to a share you can justify.
python3 stripe_radar_blocks.py --days 7
# 1,204 charge(s): 9 blocked (0.7%) - rule 0, risk 6, adaptive 3
The full code
One paginated GET against Charges, no writes — a restricted key with read access to Charges is enough. The classifier is pure and takes a single charge, because the only judgement in the whole script is which of the four kinds of block a charge represents, and that judgement is worth reading on its own.
"""Report Stripe charges that Radar blocked before authorization.
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_radar_blocks")
API = "https://api.stripe.com/v1"
def classify(charge):
"""Classify one charge. Pure, so the rules can be tested without a network.
Returns (state, detail). A blocked charge never reached the issuer, so it has
no decline code; `outcome.reason` is the only account of what happened.
"""
outcome = charge.get("outcome") or {}
if outcome.get("type") != "blocked":
return ("not-blocked", "outcome.type %r" % (outcome.get("type"),))
reason = outcome.get("reason") or "unknown"
seller = outcome.get("seller_message") or "no seller_message"
if reason == "rule":
return ("rule",
"a Radar rule you wrote stopped this before authorization: %s" % seller)
if reason in ("highest_risk_level", "elevated_risk_level"):
return ("risk",
"Radar's own %s threshold, not a rule of yours: %s" % (reason, seller))
if reason == "low_probability_of_authorization":
return ("adaptive",
"Adaptive Acceptance skipped an attempt it expected to fail. "
"Not fraud; exclude it from fraud metrics.")
return ("blocked-other", "blocked for %r: %s" % (reason, seller))
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 charges(session, since, cap):
"""Yield charges created since `since`, newest first, up to `cap`."""
seen = 0
params = {"limit": 100, "created[gte]": since}
while True:
page = get(session, "/charges", **params)
data = page.get("data", [])
for ch in data:
yield ch
seen += 1
if seen >= cap:
return
if not page.get("has_more") or not data:
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 scan (default 30)")
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
counts = {}
by_reason = {}
examples = []
scanned = 0
for ch in charges(s, since, args.max_charges):
scanned += 1
state, detail = classify(ch)
if state == "not-blocked":
continue
counts[state] = counts.get(state, 0) + 1
reason = (ch.get("outcome") or {}).get("reason") or "unknown"
n, amount = by_reason.get(reason, (0, 0))
by_reason[reason] = (n + 1, amount + int(ch.get("amount") or 0))
if len(examples) < 10:
examples.append((ch["id"], detail))
for cid, detail in examples:
log.warning("%s %s", cid, detail)
blocked = sum(counts.values())
share = (100.0 * blocked / scanned) if scanned else 0.0
log.info("%d charge(s): %d blocked (%.1f%%) - rule %d, risk %d, adaptive %d",
scanned, blocked, share, counts.get("rule", 0),
counts.get("risk", 0), counts.get("adaptive", 0))
for reason, (n, amount) in sorted(by_reason.items(), key=lambda kv: -kv[1][0]):
log.warning(" %-32s %4d charge(s), %d in minor units", reason, n, amount)
if share > 2:
log.warning(" blocked charges are over 2%% of volume, which is high enough "
"to be costing real revenue")
if counts.get("rule"):
log.warning(" repair: Dashboard > Radar > Rules, find the rule named in "
"outcome.seller_message and narrow its scope or disable it")
if counts.get("risk"):
log.warning(" repair: add a review rule before moving the block threshold, "
"so risky payments queue rather than vanish")
if counts.get("adaptive"):
log.warning(" note: low_probability_of_authorization is Adaptive Acceptance "
"working; exclude it from fraud metrics rather than 'fixing' it")
return 1 if (counts.get("rule") or counts.get("risk")) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe charges that Radar blocked before authorization.
*
* 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. Pure, so the rules can be tested without a network.
* A blocked charge never reached the issuer, so it has no decline code;
* outcome.reason is the only account of what happened.
*/
export function classify(charge) {
const outcome = charge.outcome ?? {};
if (outcome.type !== 'blocked') {
return ['not-blocked', `outcome.type ${JSON.stringify(outcome.type)}`];
}
const reason = outcome.reason ?? 'unknown';
const seller = outcome.seller_message ?? 'no seller_message';
if (reason === 'rule') {
return ['rule', `a Radar rule you wrote stopped this before authorization: ${seller}`];
}
if (reason === 'highest_risk_level' || reason === 'elevated_risk_level') {
return ['risk', `Radar's own ${reason} threshold, not a rule of yours: ${seller}`];
}
if (reason === 'low_probability_of_authorization') {
return ['adaptive',
'Adaptive Acceptance skipped an attempt it expected to fail. ' +
'Not fraud; exclude it from fraud metrics.'];
}
return ['blocked-other', `blocked for ${JSON.stringify(reason)}: ${seller}`];
}
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* charges(key, since, cap) {
let seen = 0;
const params = { limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/charges', params);
const data = page.data ?? [];
for (const ch of data) {
yield ch;
seen += 1;
if (seen >= cap) return;
}
if (!page.has_more || data.length === 0) 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 days = Number(process.env.DAYS ?? 30);
const since = Math.floor(Date.now() / 1000) - days * 86400;
const counts = new Map();
const byReason = new Map();
const examples = [];
let scanned = 0;
for await (const ch of charges(key, since, 5000)) {
scanned += 1;
const [state, detail] = classify(ch);
if (state === 'not-blocked') continue;
counts.set(state, (counts.get(state) ?? 0) + 1);
const reason = ch.outcome?.reason ?? 'unknown';
const [n, amount] = byReason.get(reason) ?? [0, 0];
byReason.set(reason, [n + 1, amount + (ch.amount ?? 0)]);
if (examples.length < 10) examples.push([ch.id, detail]);
}
for (const [id, detail] of examples) console.warn(`${id} ${detail}`);
const blocked = [...counts.values()].reduce((a, b) => a + b, 0);
const share = scanned ? (100 * blocked) / scanned : 0;
console.log(`${scanned} charge(s): ${blocked} blocked (${share.toFixed(1)}%) - ` +
`rule ${counts.get('rule') ?? 0}, risk ${counts.get('risk') ?? 0}, ` +
`adaptive ${counts.get('adaptive') ?? 0}`);
for (const [reason, [n, amount]] of [...byReason].sort((a, b) => b[1][0] - a[1][0])) {
console.warn(` ${reason.padEnd(32)} ${String(n).padStart(4)} charge(s), ` +
`${amount} in minor units`);
}
if (share > 2) {
console.warn(' blocked charges are over 2% of volume, which is high enough ' +
'to be costing real revenue');
}
if (counts.get('rule')) {
console.warn(' repair: Dashboard > Radar > Rules, find the rule named in ' +
'outcome.seller_message and narrow its scope or disable it');
}
if (counts.get('risk')) {
console.warn(' repair: add a review rule before moving the block threshold, ' +
'so risky payments queue rather than vanish');
}
if (counts.get('adaptive')) {
console.warn(' note: low_probability_of_authorization is Adaptive Acceptance ' +
"working; exclude it from fraud metrics rather than 'fixing' it");
}
process.exitCode = (counts.get('rule') || counts.get('risk')) ? 1 : 0;
}
// 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 case the tests exist for is low_probability_of_authorization. It sits in the same field, with the same outcome.type, as a rule that is eating your revenue, and it is the one block you should not touch. Anything that lumps it in with the others produces a block rate that looks alarming and points at rules that had nothing to do with it.
from stripe_radar_blocks import classify
def charge(reason, type_="blocked", seller="Stopped"):
return {"outcome": {"type": type_, "reason": reason, "seller_message": seller,
"network_status": "not_sent_to_network"}}
def test_custom_rule_is_named_as_yours():
state, detail = classify(charge("rule", seller="Blocked by your rule"))
assert state == "rule"
assert "rule you wrote" in detail
def test_radar_threshold_is_not_confused_with_a_custom_rule():
state, detail = classify(charge("highest_risk_level"))
assert state == "risk"
assert "not a rule of yours" in detail
def test_adaptive_acceptance_is_not_fraud():
# The whole point of the note: this one is working correctly.
state, detail = classify(charge("low_probability_of_authorization"))
assert state == "adaptive"
assert "Not fraud" in detail
def test_issuer_declines_are_a_different_investigation():
assert classify(charge(None, type_="issuer_declined"))[0] == "not-blocked"
def test_missing_outcome_is_not_counted_as_blocked():
assert classify({})[0] == "not-blocked"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-radar-blocks.mjs';
function charge(reason, type = 'blocked', seller = 'Stopped') {
return { outcome: { type, reason, seller_message: seller,
network_status: 'not_sent_to_network' } };
}
test('custom rule is named as yours', () => {
const [state, detail] = classify(charge('rule', 'blocked', 'Blocked by your rule'));
assert.equal(state, 'rule');
assert.match(detail, /rule you wrote/);
});
test('radar threshold is not confused with a custom rule', () => {
const [state, detail] = classify(charge('highest_risk_level'));
assert.equal(state, 'risk');
assert.match(detail, /not a rule of yours/);
});
test('adaptive acceptance is not fraud', () => {
const [state, detail] = classify(charge('low_probability_of_authorization'));
assert.equal(state, 'adaptive');
assert.match(detail, /Not fraud/);
});
test('issuer declines are a different investigation', () => {
assert.equal(classify(charge(null, 'issuer_declined'))[0], 'not-blocked');
});
test('missing outcome is not counted as blocked', () => {
assert.equal(classify({})[0], 'not-blocked');
});
FAQ
What is the difference between a blocked charge and a declined one?
A blocked charge was stopped by Radar before Stripe sent it to the card network, so outcome.network_status reads not_sent_to_network and there is no issuer decline code anywhere on the object. A declined charge reached the issuer and came back with a reason. Only the second one is something the customer's bank can explain.
How high is a normal block rate?
It depends entirely on the business, but blocked charges above roughly 2% of volume are worth an afternoon of investigation, and a single outcome.reason accounting for the majority of them is worth one regardless of the rate. The number that settles the question is the summed amount, not the count.
Should I turn off the rule the script points at?
Narrow it before you delete it. A rule that blocks a whole country during a fraud wave may still be doing something useful against a narrower slice, and scoping it to an amount band or a BIN range usually returns most of the good traffic while keeping that. Deleting outright is a decision to make deliberately, not as a first move.
What do I do about low_probability_of_authorization?
Nothing. That is Adaptive Acceptance predicting the issuer would decline and skipping the attempt so you do not pay a network fee for a failure. The correct action is to stop counting those charges as fraud prevention, because they distort every block-rate number you look at afterwards.
Can a restricted key read outcome data?
Yes. outcome is part of the Charge object, so read access to Charges is all this script needs. It never creates, refunds, or updates anything, and it cannot change a Radar rule even if you wanted it to, which is the point.
Related field notes
- High-risk orders nobody actions
- Declined card leaves the order stuck pending
- 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.
- Declines — Stripe Docs
- The Charge object — Stripe API reference
- Radar rules — Stripe Docs
- Reviewing payments — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.