Diagnostic Stripe
disputes closed as lost were never actually contested
Somebody finally asks what the dispute win rate is. The Dashboard says most of them are lost, and the conclusion in the room is that disputes are unwinnable and not worth the effort. Nobody in the room can say how many of those losses were ever answered, and until someone can, that conclusion is unsupported.
Page GET /v1/disputes?created[gte]=<now-365d> and split the losses in two. A dispute with status of lost and evidence_details.submission_count of 0 was never contested; it was forfeited when the deadline passed. Anything above zero was actually judged.
Report two numbers, not one: the share of losses that were forfeits, and the loss rate over contested disputes only. The first is recoverable process loss, the second is your real win rate. A forfeit share above roughly 30% means there is no dispute workflow, only a dispute list.
The problem in plain words
The Dashboard shows outcomes. It does not show effort, so a dispute lost after a careful evidence package and a dispute lost because nobody opened the email look exactly alike in the headline number. That single number then gets used to make a decision — usually "disputes are not worth fighting" — which quietly guarantees the number stays where it is.
The cost compounds in a second place. Chargeback rate is measured by the networks, and sustained high rates put an account into a monitoring programme with fees and remediation plans attached. Forfeited disputes count toward that just as decided ones do, so a process gap becomes an account-standing problem without ever appearing as one.
Why it happens
Forfeits and defeats are the same status. lost is lost. Nothing in the object separates the two except submission_count, and nothing in the interface puts that field next to the outcome, so the distinction only exists if somebody goes looking for it.
The denominator is wrong in the obvious calculation. Dividing losses by all disputes mixes the ones you fought with the ones you skipped, and produces a win rate that is not a measure of anything you do. The number that answers "is fighting worth it" is losses over contested disputes only, and it is frequently far better than the headline suggests.
The mechanism is invisible in aggregate but obvious per dispute. Each individual forfeit has a story — the person who handled disputes left, the deadline landed over a holiday, the notification went to an inbox nobody owns — and each one sounds like a one-off. Counted together over a year they form a rate, and rates get fixed where anecdotes do not.
Nobody measures what they believe is unwinnable. The belief and the absence of measurement hold each other up. Breaking that loop needs exactly one number that anybody can reproduce, which is what this script prints.
The fix, as a flow
The script counts three numbers and reports two ratios, because the single headline loss rate mixes disputes you fought with disputes you never opened and measures neither.
How to fix it
Pull a year of disputes, not a month
GET /v1/disputes?created[gte]=<unix>&limit=100, paginated. Disputes are low-volume for most accounts, so a short window gives you a ratio built on four data points. A year is usually enough to be worth arguing about.
Count three things and only three
Disputes closed as won, disputes closed as lost, and the subset of the lost ones with submission_count of zero. Anything still open belongs to the deadline sweep, not to this measurement, and including it drags the ratio around for no reason.
Report the forfeit share
Forfeits divided by losses. This is the number that is recoverable by process alone, with no change to the evidence you collect or the products you sell. Zero is achievable; most first measurements are not close to it.
Report the contested loss rate separately
Contested losses over contested losses plus wins. This is what your evidence is actually worth. If it is good, the forfeit share is money left on the table. If it is poor as well, the fix is in what you collect at payment time rather than in who reads the inbox.
Close the loop with a daily deadline sweep
This note measures the damage; it does not stop it. The sweep on evidence_details.due_by is what changes the number, and re-running this check a quarter later is what proves it did.
Pre-populate the evidence at payment time
Customer IP, email, shipping address and product description passed on every payment make a response something you assemble in minutes rather than a research project, and they are what network programmes such as Visa Compelling Evidence 3.0 are assessed against.
How to check it worked
Re-run the check a quarter after the deadline sweep is in place. The forfeit share should be falling toward zero, and the contested loss rate should barely move, because it was always measuring something else.
python3 stripe_dispute_forfeits.py --days 90
# contested 6 loss(es), all of them answered; the 11 contested dispute(s) lost 55% of the time
The full code
One paginated GET against /v1/disputes with a created[gte] filter, and no writes at all. The arithmetic is a pure function taking three integers, which keeps the two ratios — the forfeit share and the contested loss rate — visible and testable instead of buried in a counting loop where a wrong denominator would never be noticed.
"""Measure how many lost Stripe disputes were forfeited rather than decided.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Disputes. The repair is a process change, printed for a human, 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_dispute_forfeits")
API = "https://api.stripe.com/v1"
# Above this share of losses, the dispute process is not merely leaky.
FORFEIT_ALARM = 0.30
def verdict(lost, forfeited, won):
"""Classify a window of closed disputes. Pure, so both ratios can be tested.
`forfeited` is the subset of `lost` that closed with submission_count 0,
meaning the deadline passed rather than the evidence failing.
Returns (state, detail).
"""
if lost + won == 0:
return ("no_disputes", "no dispute closed as won or lost in this window")
if forfeited > lost:
return ("unknown",
"%d forfeit(s) against %d loss(es); the counts disagree"
% (forfeited, lost))
if lost == 0:
return ("clean", "%d dispute(s) closed, none lost" % won)
contested_lost = lost - forfeited
denom = contested_lost + won
if denom:
rate = ("the %d contested dispute(s) lost %.0f%% of the time"
% (denom, 100.0 * contested_lost / denom))
else:
rate = "nothing was contested, so there is no real loss rate to quote"
if forfeited == 0:
return ("contested", "%d loss(es), every one answered; %s" % (lost, rate))
share = 100.0 * forfeited / lost
body = ("%d of %d loss(es) (%.0f%%) closed with submission_count 0; %s"
% (forfeited, lost, share, rate))
if forfeited / float(lost) >= FORFEIT_ALARM:
return ("absent", body + ". At this share there is no dispute workflow, "
"only a dispute list.")
return ("leaking", body + ". Each of those was recoverable process loss.")
def get(session, path, params=None):
r = session.get(API + path, params=params or {}, 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 tally(session, since, limit):
"""Count won, lost and forfeited disputes created since `since`.
Open disputes are ignored on purpose: they belong to the deadline sweep, and
counting them here moves the ratio for reasons that have nothing to do with
how the closed ones went.
"""
lost = forfeited = won = seen = 0
params = {"limit": 100, "created[gte]": int(since)}
while True:
page = get(session, "/disputes", params)
data = page.get("data", [])
for d in data:
seen += 1
status = d.get("status")
if status == "won":
won += 1
elif status == "lost":
lost += 1
ed = d.get("evidence_details") or {}
if not (ed.get("submission_count") or 0):
forfeited += 1
if not data or not page.get("has_more") or seen >= limit:
break
params["starting_after"] = data[-1]["id"]
return lost, forfeited, won
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=365,
help="how far back to count closed disputes")
ap.add_argument("--max-disputes", type=int, default=5000,
help="stop paginating after this many disputes")
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 = time.time() - args.days * 86400
lost, forfeited, won = tally(s, since, args.max_disputes)
state, detail = verdict(lost, forfeited, won)
line = "%-12s %s" % (state, detail)
if state in ("no_disputes", "clean", "contested"):
log.info(line)
return 0
log.warning(line)
log.warning(" repair: sweep evidence_details.due_by daily and route each "
"dispute to a named human before it is 72 hours out")
log.warning(" and pass customer IP, email, shipping address and product "
"description on every payment, so a response is a review "
"rather than a research project")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Measure how many lost Stripe disputes were forfeited rather than decided.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Disputes. The repair is a process change, printed for a human.
*/
const API = 'https://api.stripe.com/v1';
// Above this share of losses, the dispute process is not merely leaky.
export const FORFEIT_ALARM = 0.30;
/**
* Classify a window of closed disputes. Pure, so both ratios can be tested.
* `forfeited` is the subset of `lost` that closed with submission_count 0.
*/
export function verdict(lost, forfeited, won) {
if (lost + won === 0) {
return ['no_disputes', 'no dispute closed as won or lost in this window'];
}
if (forfeited > lost) {
return ['unknown',
`${forfeited} forfeit(s) against ${lost} loss(es); the counts disagree`];
}
if (lost === 0) return ['clean', `${won} dispute(s) closed, none lost`];
const contestedLost = lost - forfeited;
const denom = contestedLost + won;
const rate = denom
? `the ${denom} contested dispute(s) lost ` +
`${(100 * contestedLost / denom).toFixed(0)}% of the time`
: 'nothing was contested, so there is no real loss rate to quote';
if (forfeited === 0) {
return ['contested', `${lost} loss(es), every one answered; ${rate}`];
}
const share = (100 * forfeited / lost).toFixed(0);
const body = `${forfeited} of ${lost} loss(es) (${share}%) closed with ` +
`submission_count 0; ${rate}`;
if (forfeited / lost >= FORFEIT_ALARM) {
return ['absent', body +
'. At this share there is no dispute workflow, only a dispute list.'];
}
return ['leaking', body + '. Each of those was recoverable process loss.'];
}
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 tally(key, since, limit = 5000) {
let lost = 0, forfeited = 0, won = 0, seen = 0;
const params = { limit: 100, 'created[gte]': Math.floor(since) };
for (;;) {
const page = await get(key, '/disputes', params);
const data = page.data ?? [];
for (const d of data) {
seen += 1;
if (d.status === 'won') won += 1;
else if (d.status === 'lost') {
lost += 1;
if (!((d.evidence_details ?? {}).submission_count ?? 0)) forfeited += 1;
}
}
if (data.length === 0 || !page.has_more || seen >= limit) break;
params.starting_after = data[data.length - 1].id;
}
return { lost, forfeited, won };
}
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.argv[2] ?? 365);
const since = Date.now() / 1000 - days * 86400;
const { lost, forfeited, won } = await tally(key, since);
const [state, detail] = verdict(lost, forfeited, won);
const line = `${state.padEnd(12)} ${detail}`;
if (state === 'no_disputes' || state === 'clean' || state === 'contested') {
console.log(line);
return;
}
console.warn(line);
console.warn(' repair: sweep evidence_details.due_by daily and route each ' +
'dispute to a named human before it is 72 hours out');
console.warn(' and pass customer IP, email, shipping address and product ' +
'description on every payment, so a response is a review ' +
'rather than a research project');
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 denominators. A forfeit counted into the contested loss rate makes the evidence look worse than it is, an account with no disputes at all must not report a division by zero as a perfect record, and 30% has to fire at exactly 30% rather than just above it.
from stripe_dispute_forfeits import verdict
def test_no_closed_disputes_is_not_a_perfect_record():
state, _ = verdict(0, 0, 0)
assert state == "no_disputes"
def test_losses_that_were_all_answered_report_the_real_loss_rate():
# 4 losses, none forfeited, 6 wins: 4 of 10 contested disputes lost.
state, detail = verdict(4, 0, 6)
assert state == "contested"
assert "40%" in detail
def test_forfeits_are_excluded_from_the_contested_loss_rate():
# 10 losses, 2 forfeited, 8 wins: the contested rate is 8 of 16, not 10 of 18.
state, detail = verdict(10, 2, 8)
assert state == "leaking"
assert "16 contested" in detail
assert "50%" in detail
def test_thirty_percent_forfeits_is_the_alarm_and_it_is_inclusive():
assert verdict(100, 29, 0)[0] == "leaking"
state, detail = verdict(10, 3, 0)
assert state == "absent"
assert "no dispute workflow" in detail
def test_every_loss_forfeited_has_no_loss_rate_to_quote():
state, detail = verdict(5, 5, 0)
assert state == "absent"
assert "nothing was contested" in detail
assert verdict(1, 2, 0)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-dispute-forfeits.mjs';
test('no closed disputes is not a perfect record', () => {
assert.equal(verdict(0, 0, 0)[0], 'no_disputes');
});
test('losses that were all answered report the real loss rate', () => {
const [state, detail] = verdict(4, 0, 6);
assert.equal(state, 'contested');
assert.match(detail, /40% of the time/);
});
test('forfeits are excluded from the contested loss rate', () => {
const [state, detail] = verdict(10, 2, 8);
assert.equal(state, 'leaking');
assert.match(detail, /16 contested/);
assert.match(detail, /50% of the time/);
});
test('thirty percent forfeits is the alarm and it is inclusive', () => {
assert.equal(verdict(100, 29, 0)[0], 'leaking');
const [state, detail] = verdict(10, 3, 0);
assert.equal(state, 'absent');
assert.match(detail, /no dispute workflow/);
});
test('every loss forfeited has no loss rate to quote', () => {
const [state, detail] = verdict(5, 5, 0);
assert.equal(state, 'absent');
assert.match(detail, /nothing was contested/);
assert.equal(verdict(1, 2, 0)[0], 'unknown');
});
FAQ
How do I tell a forfeited dispute from one I lost on the evidence?
evidence_details.submission_count on the dispute object. A dispute with status lost and a submission count of zero was never answered; the deadline simply passed. Anything above zero was judged on what you sent.
What is a normal forfeit share?
Zero is the target, because a forfeit is a process failure rather than a business outcome. Anything above zero is recoverable, and above roughly 30% the honest description is that disputes are not being worked at all, whatever the calendar says.
Why report the contested loss rate separately?
Because it is the only number that measures your evidence. Mixing forfeits into the denominator makes a good evidence package look like a poor one and supports the conclusion that fighting disputes is pointless, which then produces more forfeits.
Do forfeited disputes still count toward my chargeback rate?
Yes. The networks count the dispute, not your effort. Sustained high rates lead to monitoring programmes with fees and remediation requirements attached, so a process gap in the inbox becomes an account-standing problem.
Can this script fix the disputes it finds?
No, and by then nothing can: a closed dispute is closed. It measures a window that has already passed so the process change can be justified and, later, shown to have worked. The check that prevents the next one is the daily sweep on evidence_details.due_by.
Related field notes
- Disputes are hours from due_by with no evidence attached
- Checkout Sessions carry no ID that maps back to your order
- Radar blocks payments and nobody reads the block reasons
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 dispute object — Stripe API reference
- Respond to disputes — Stripe Docs
- Measuring disputes — Stripe Docs
- List all disputes — 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.