Diagnostic Stripe
charges captured after AVS and CVC verification failed
The dispute says the cardholder never made the purchase, and when you open the charge to build a case you find the billing postal code did not match the issuer's records and the security code was wrong. Stripe recorded both facts at the time. Nothing was configured to act on them, so the payment was captured and the goods shipped.
Start at the account: GET /v1/account and read settings.card_payments.decline_on.avs_failure and .cvc_failure. Both default to false, which means Stripe records a failed check and captures the payment anyway.
Then paginate GET /v1/charges and look at payment_method_details.card.checks. Flag any captured, succeeded charge where cvc_check, address_postal_code_check or address_line1_check is "fail". Count the ones where all three are null separately: that is data you never collected, which is the deeper problem.
The problem in plain words
An issuer can approve a payment that fails CVC or AVS. It weighs a great many other signals and the mismatch is only one of them, so an approval is not a statement that the details were correct. Stripe faithfully records what the issuer said about each check and, unless you told it otherwise, treats the approval as the answer.
What that costs you shows up at dispute time. A fraudulent-charge dispute is defended with evidence that the real cardholder made the purchase, and the strongest evidence available is that the billing details matched. When the record shows they demonstrably did not, there is no case to make. Stripe's own guidance on this is blunt: if verification fails, consider rejecting the payment, because it might indicate fraud.
Why it happens
The defaults are off, and off is invisible. decline_on.avs_failure and decline_on.cvc_failure are both false on a new account. Nothing prompts you during onboarding, nothing warns you later, and the checks still appear on every charge, which makes it look like something is consuming them.
Turning them on bluntly breaks wallets. Apple Pay and Google Pay often do not supply a postal code or a CVC at all, so a hard decline on failure can also reject payments that were never going to have the data. That is why Stripe ships risk-scored variants of both rules, and why the blunt account settings are a worse first move than the Radar rules.
A null check is not a passing check. The two are easy to conflate in a query and they mean opposite things. null across all three fields means nothing was ever collected: the payment form never asked for a postal code, so there was no AVS request to fail. That is a form problem, and no rule will fix it.
The failure is only expensive in aggregate. One captured payment with a failed AVS check is not evidence of anything. A steady few percent of them, disproportionately represented among your disputes, is a control you never switched on, and only counting shows you which one it is.
The fix, as a flow
The script reads the account's decline_on settings first, then every card charge's checks object, because a null check and a failed check sit in the same field and need opposite repairs.
How to fix it
Read the account settings first
GET /v1/account, then settings.card_payments.decline_on. This one object tells you whether anything at all is acting on verification failures, and it costs a single request. If both flags are false, everything you find in the charges was inevitable.
Page 90 days of charges and read the checks object
payment_method_details.card.checks carries three fields: cvc_check, address_postal_code_check and address_line1_check. The values you care about are "fail", and separately null.
Flag failures that were captured, and hold the ones that were not
A failed check on a charge with captured == false is still a decision you can make. A failed check on a captured charge is a refund decision, and one you should probably make quickly rather than after a dispute makes it for you.
Count uncollected checks as their own category
All three fields null on a card charge means the data never reached the issuer. Force collection at the source: set billing_address_collection to required on Checkout Sessions, or collect the postal code in the Payment Element. Until you do, an AVS rule has nothing to evaluate.
Enable the risk-scored Radar rules rather than the blunt account flags
In Dashboard, Radar, then Rules, switch on the built-ins for postal code verification failing based on risk score, and CVC verification failing based on risk score. The scoring is what stops them rejecting wallet payments that never supplied the data in the first place.
How to check it worked
Re-run over a window that starts after the rules were enabled. Captured failures should go to zero, and the uncollected count should fall as the form change reaches customers.
python3 stripe_avs_cvc_checks.py --days 30
# decline_on: avs=True cvc=True
# 1,860 card charge(s): 0 captured on a failed check, 31 never collected
The full code
One account read and one paginated GET over Charges, no writes — read access to Account and Charges is enough. The classifier is pure and takes the checks object, the capture flag and the account's decline_on settings together, because whether a captured failure is a surprise or a configured choice depends on all three.
"""Report Stripe charges captured after an AVS or CVC check came back failed.
Read only. One account read and one paginated GET: give this a RESTRICTED key
with read access to Account 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_avs_cvc_checks")
API = "https://api.stripe.com/v1"
CHECK_FIELDS = ("cvc_check", "address_postal_code_check", "address_line1_check")
INCONCLUSIVE = (None, "unavailable", "unchecked")
def _covered(field, decline_on):
"""True when the account is configured to decline on this check failing."""
settings = decline_on or {}
if field == "cvc_check":
return bool(settings.get("cvc_failure"))
return bool(settings.get("avs_failure"))
def verdict(checks, captured, decline_on):
"""Classify one charge's verification result. Pure, so it tests offline.
`checks` is payment_method_details.card.checks, or None when the charge was
not a card payment. `decline_on` is settings.card_payments.decline_on from
the account. Returns (state, detail).
"""
if checks is None:
return ("not_card", "no card checks on this charge")
values = {f: checks.get(f) for f in CHECK_FIELDS}
if all(v is None for v in values.values()):
return ("uncollected",
"no AVS or CVC result at all: the details were never collected, so "
"there was nothing for the issuer to verify")
failed = sorted(f for f, v in values.items() if v == "fail")
if failed and captured:
uncovered = [f for f in failed if not _covered(f, decline_on)]
if uncovered:
return ("captured_on_fail",
"%s failed and the charge was captured: decline_on is not set "
"for %s" % (", ".join(failed), ", ".join(uncovered)))
return ("captured_despite_setting",
"%s failed and the charge was captured even though decline_on "
"covers it: check the Radar rules are enabled" % ", ".join(failed))
if failed:
return ("held",
"%s failed and the charge is not captured: this is still a decision "
"you can make" % ", ".join(failed))
if any(values[f] in INCONCLUSIVE for f in CHECK_FIELDS):
missing = sorted(f for f in CHECK_FIELDS if values[f] in INCONCLUSIVE)
return ("unverified", "no usable result for %s" % ", ".join(missing))
return ("verified", "every collected check passed")
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):
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 card_checks(charge):
"""payment_method_details.card.checks, or None when this is not a card."""
details = charge.get("payment_method_details") or {}
if details.get("type") != "card":
return None
return (details.get("card") or {}).get("checks")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=90, help="how far back to read charges")
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})
account = get(s, "/account")
settings = account.get("settings") or {}
decline_on = (settings.get("card_payments") or {}).get("decline_on") or {}
log.info("decline_on: avs=%s cvc=%s",
bool(decline_on.get("avs_failure")), bool(decline_on.get("cvc_failure")))
since = int(time.time() - args.days * 86400)
charges = page(s, "/charges", args.max_charges, **{"created[gte]": since})
counts = {}
flagged = []
cards = 0
for ch in charges:
checks = card_checks(ch)
if checks is None and (ch.get("payment_method_details") or {}).get("type") != "card":
continue
cards += 1
state, detail = verdict(checks, ch.get("captured"), decline_on)
counts[state] = counts.get(state, 0) + 1
if state in ("captured_on_fail", "captured_despite_setting", "held"):
flagged.append((ch, state, detail))
log.info("%d card charge(s): %d captured on a failed check, %d never collected",
cards,
counts.get("captured_on_fail", 0) + counts.get("captured_despite_setting", 0),
counts.get("uncollected", 0))
for ch, state, detail in flagged:
log.warning("%-24s %s %s", state, ch.get("id"), detail)
if not flagged and not counts.get("uncollected"):
return 0
if flagged:
log.warning(" enable the risk-scored built-ins in Dashboard, Radar, Rules: "
"postal code verification fails based on risk score, and CVC "
"verification fails based on risk score")
if counts.get("uncollected"):
log.warning(" %d charge(s) had no checks at all. Collect the details: set "
"billing_address_collection to required on Checkout Sessions, or "
"collect the postal code in the Payment Element.",
counts["uncollected"])
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe charges captured after an AVS or CVC check came back failed.
*
* Read only. One account read and one paginated GET: give this a RESTRICTED key
* with read access to Account and Charges. The repair is printed, never run.
*/
const API = 'https://api.stripe.com/v1';
export const CHECK_FIELDS = ['cvc_check', 'address_postal_code_check',
'address_line1_check'];
const INCONCLUSIVE = new Set([null, undefined, 'unavailable', 'unchecked']);
/** True when the account is configured to decline on this check failing. */
function covered(field, declineOn) {
const s = declineOn ?? {};
return field === 'cvc_check' ? Boolean(s.cvc_failure) : Boolean(s.avs_failure);
}
/**
* Classify one charge's verification result. Pure, so it tests offline.
* `checks` is payment_method_details.card.checks, or null for a non-card charge.
*/
export function verdict(checks, captured, declineOn) {
if (checks === null || checks === undefined) {
return ['not_card', 'no card checks on this charge'];
}
const values = Object.fromEntries(CHECK_FIELDS.map((f) => [f, checks[f] ?? null]));
if (CHECK_FIELDS.every((f) => values[f] === null)) {
return ['uncollected',
'no AVS or CVC result at all: the details were never collected, so there ' +
'was nothing for the issuer to verify'];
}
const failed = CHECK_FIELDS.filter((f) => values[f] === 'fail').sort();
if (failed.length && captured) {
const uncovered = failed.filter((f) => !covered(f, declineOn));
if (uncovered.length) {
return ['captured_on_fail',
`${failed.join(', ')} failed and the charge was captured: decline_on is ` +
`not set for ${uncovered.join(', ')}`];
}
return ['captured_despite_setting',
`${failed.join(', ')} failed and the charge was captured even though ` +
'decline_on covers it: check the Radar rules are enabled'];
}
if (failed.length) {
return ['held',
`${failed.join(', ')} failed and the charge is not captured: this is still ` +
'a decision you can make'];
}
const missing = CHECK_FIELDS.filter((f) => INCONCLUSIVE.has(values[f])).sort();
if (missing.length) {
return ['unverified', `no usable result for ${missing.join(', ')}`];
}
return ['verified', 'every collected check passed'];
}
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;
}
function cardChecks(charge) {
const details = charge.payment_method_details ?? {};
if (details.type !== 'card') return undefined;
return (details.card ?? {}).checks ?? null;
}
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 account = await get(key, '/account');
const declineOn = account.settings?.card_payments?.decline_on ?? {};
console.log(`decline_on: avs=${Boolean(declineOn.avs_failure)} ` +
`cvc=${Boolean(declineOn.cvc_failure)}`);
const days = Number(process.env.DAYS ?? 90);
const since = Math.floor(Date.now() / 1000 - days * 86400);
const charges = await page(key, '/charges', 5000, { 'created[gte]': since });
const counts = {};
const flagged = [];
let cards = 0;
for (const ch of charges) {
const checks = cardChecks(ch);
if (checks === undefined) continue;
cards += 1;
const [state, detail] = verdict(checks, ch.captured, declineOn);
counts[state] = (counts[state] ?? 0) + 1;
if (state === 'captured_on_fail' || state === 'captured_despite_setting' ||
state === 'held') {
flagged.push([ch, state, detail]);
}
}
const bad = (counts.captured_on_fail ?? 0) + (counts.captured_despite_setting ?? 0);
console.log(`${cards} card charge(s): ${bad} captured on a failed check, ` +
`${counts.uncollected ?? 0} never collected`);
for (const [ch, state, detail] of flagged) {
console.warn(`${state.padEnd(24)} ${ch.id} ${detail}`);
}
if (flagged.length === 0 && !counts.uncollected) return;
if (flagged.length) {
console.warn(' enable the risk-scored built-ins in Dashboard, Radar, Rules: ' +
'postal code verification fails based on risk score, and CVC ' +
'verification fails based on risk score');
}
if (counts.uncollected) {
console.warn(` ${counts.uncollected} charge(s) had no checks at all. Collect the ` +
'details: set billing_address_collection to required on Checkout ' +
'Sessions, or collect the postal code in the Payment Element.');
}
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 exist mostly to keep null and "fail" apart. Those two values sit in the same field, mean opposite things, and have completely different repairs: one is a Radar rule you never enabled, the other is a checkout form that never asked for a postal code. A classifier that folds them together produces a report that sends you to the wrong page.
from stripe_avs_cvc_checks import verdict
OFF = {"avs_failure": False, "cvc_failure": False}
ON = {"avs_failure": True, "cvc_failure": True}
def test_non_card_charges_are_out_of_scope():
assert verdict(None, True, OFF)[0] == "not_card"
def test_all_null_checks_means_nothing_was_ever_collected():
# Not the same as passing. There was no AVS request to fail, so no rule
# would have helped: the checkout form is the thing to fix.
state, detail = verdict({}, True, OFF)
assert state == "uncollected"
assert "never collected" in detail
def test_a_failed_check_on_a_captured_charge_names_the_missing_setting():
checks = {"cvc_check": "pass", "address_postal_code_check": "fail",
"address_line1_check": "pass"}
state, detail = verdict(checks, True, OFF)
assert state == "captured_on_fail"
assert "address_postal_code_check" in detail
def test_a_failure_the_account_declines_on_is_a_different_problem():
checks = {"cvc_check": "fail", "address_postal_code_check": "pass",
"address_line1_check": "pass"}
assert verdict(checks, True, ON)[0] == "captured_despite_setting"
# Uncaptured is still a live decision, whatever the settings say.
assert verdict(checks, False, OFF)[0] == "held"
def test_passing_and_inconclusive_checks_are_told_apart():
passed = {"cvc_check": "pass", "address_postal_code_check": "pass",
"address_line1_check": "pass"}
assert verdict(passed, True, OFF)[0] == "verified"
partial = dict(passed, address_line1_check="unavailable")
assert verdict(partial, True, OFF)[0] == "unverified"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-avs-cvc-checks.mjs';
const OFF = { avs_failure: false, cvc_failure: false };
const ON = { avs_failure: true, cvc_failure: true };
test('non card charges are out of scope', () => {
assert.equal(verdict(null, true, OFF)[0], 'not_card');
});
test('all null checks means nothing was ever collected', () => {
const [state, detail] = verdict({}, true, OFF);
assert.equal(state, 'uncollected');
assert.ok(detail.includes('never collected'));
});
test('a failed check on a captured charge names the missing setting', () => {
const checks = { cvc_check: 'pass', address_postal_code_check: 'fail',
address_line1_check: 'pass' };
const [state, detail] = verdict(checks, true, OFF);
assert.equal(state, 'captured_on_fail');
assert.ok(detail.includes('address_postal_code_check'));
});
test('a failure the account declines on is a different problem', () => {
const checks = { cvc_check: 'fail', address_postal_code_check: 'pass',
address_line1_check: 'pass' };
assert.equal(verdict(checks, true, ON)[0], 'captured_despite_setting');
assert.equal(verdict(checks, false, OFF)[0], 'held');
});
test('passing and inconclusive checks are told apart', () => {
const passed = { cvc_check: 'pass', address_postal_code_check: 'pass',
address_line1_check: 'pass' };
assert.equal(verdict(passed, true, OFF)[0], 'verified');
assert.equal(verdict({ ...passed, address_line1_check: 'unavailable' }, true, OFF)[0],
'unverified');
});
FAQ
If the issuer approved it, why is a failed AVS check my problem?
The issuer weighs many signals and can approve despite a mismatch; the approval is a lending decision, not a statement that the billing details were correct. The liability for a fraudulent card-not-present payment stays with you, and at dispute time the mismatch is the record you have to argue against.
Should I just set decline_on.avs_failure and cvc_failure to true?
Those flags decline bluntly, on any failure, including wallet payments that never supplied a postal code or a CVC to begin with. Stripe's risk-scored Radar rules do the same job while taking the rest of the signal into account, which is why they are the better first move.
What is the difference between a null check and a failed one?
A failure means the issuer compared the value you sent and it did not match. A null means you never sent one, so nothing was compared. The first is a rule you have not enabled; the second is a checkout form that does not ask for the data. Fixing the rule does nothing about the nulls.
How do I start collecting postal codes?
On Checkout, set billing_address_collection to required when you create the session. On a custom flow, collect the postal code in the Payment Element so it is sent with the confirmation. Either way the AVS result appears on subsequent charges and the null count falls.
Does this script need write access to fix anything?
No, and it deliberately cannot. It reads the account settings and the charges, prints which rules to enable and which charges to look at, and leaves every decision about refunding a captured payment to you.
Related field notes
- Highest-risk charges succeed instead of being blocked
- A dispute deadline is 72 hours out with no evidence attached
- No statement descriptor, so customers dispute what they see
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 account object — Stripe API reference
- Rules — Stripe Docs
- Preventing disputes and fraud — Stripe Docs
- The charge 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.