Diagnostic Stripe
disputes are hours from due_by with no evidence attached
A customer disputed a charge three weeks ago. The notification went to the shared billing inbox, where it sat under invoices. The dispute closed yesterday as lost, the funds went back, the dispute fee did not come back, and the delivery confirmation that would have answered it was in a support ticket the whole time.
Page GET /v1/disputes and read evidence_details on everything whose status is needs_response. Three fields decide the outcome: due_by is the deadline, has_evidence says whether anything has been staged, and submission_count says whether it was ever actually sent.
Alert when due_by - now is under 72 hours and submission_count is still 0. If past_due is already true while the status is needs_response, that dispute is over: miss the deadline and you lose automatically, and the disputed funds cannot be retrieved.
The problem in plain words
The dispute itself is not the failure. Disputes are a cost of taking cards, and a fair number of them are genuinely indefensible. The failure is the ones you would have won, closed without anyone opening them, because the deadline arrived before the notification was read.
What makes it hard to see is that the outcome looks identical either way. A dispute lost on the evidence and a dispute lost because nobody replied both appear in the Dashboard as lost, both take the funds back, and both keep the fee. There is no line item anywhere that says "this one was forfeited", so the first honest measurement of the problem is usually a script, and the first symptom is a chargeback rate creeping toward the threshold where the card networks start taking an interest.
Why it happens
The clock is short and it is not yours. The response window is roughly 7 to 21 days depending on the card network, and it starts when the network files the dispute rather than when you read about it. A week of that can be gone before the email is opened, and none of it is negotiable afterwards.
Nothing pushes a reminder as it approaches. due_by is right there on the object, but Stripe does not escalate as it nears. The notification arrives once, at the start, usually to whatever address the account was created with. If that is a shared inbox that four people half-watch, the dispute is now everyone's job and therefore nobody's.
Evidence submits exactly once. You cannot send a partial response now and add the tracking number tomorrow. That makes the correct behaviour "assemble everything, then submit", which is also the behaviour most likely to stall for a week waiting on a colleague, so a dispute can sit at has_evidence: true and submission_count: 0 right through its deadline. Staged is not submitted, and only submission_count can tell you which one you are looking at.
The window is measured in hours near the end, not days. A weekly check on a 10-day deadline can hand you a dispute with four hours left, on a Saturday. This is a daily check or it is decoration.
The fix, as a flow
The script measures due_by in hours rather than days, because the last stretch of a dispute window is where the decision actually gets made and a day is too coarse a unit to schedule against.
How to fix it
List disputes and keep only the ones still open
GET /v1/disputes?limit=100, paginated. The statuses that still need something from you are needs_response and warning_needs_response; under_review means the evidence is already in, and won, lost and warning_closed are finished.
Turn due_by into hours, not days
due_by is a unix timestamp. Subtract now and divide by 3600. Days are the wrong unit at the end of the window: "2 days left" and "38 hours left, and one of them is a weekend" are the same number and very different tickets.
Separate staged evidence from submitted evidence
has_evidence goes true as soon as a single field is saved, which is why it reads as reassuring and is not. submission_count is the field that says the response actually went to the network. A dispute with evidence staged and a submission count of zero is the most expensive state on this list, because somebody already did the work.
Check enhanced eligibility before assembling anything
enhanced_eligibility_types containing visa_compelling_evidence_3 means Stripe can pre-populate most of the response from prior transactions with the same customer. That turns a two-hour evidence hunt into a review, so it is worth reading before anyone starts collecting screenshots.
Decide deliberately, including the decision not to fight
Some disputes are not worth answering, and accepting one with POST /v1/disputes/{id}/close is a legitimate outcome. What this check is for is making that a decision somebody took rather than a deadline that passed.
Run it daily and route it to a person
One paginated GET. Anything that fires into a channel a human reads every morning converts a class of losses that are invisible in the numbers into a queue with a length.
How to check it worked
Re-run the script after the responses go in. Everything answered moves to under_review, and nothing should be inside the 72-hour window unanswered.
python3 stripe_dispute_deadlines.py
# 14 dispute(s) read, 0 needing a response now
The full code
One paginated GET against /v1/disputes and nothing else — a restricted key with read access to Disputes is enough, and is what you should give it. The classification is a pure function because the whole check is deadline arithmetic, and an off-by-one on the boundary is a check that tells you about a dispute after it closed.
"""Report Stripe disputes whose response deadline is about to pass.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Disputes. The response is printed, never submitted, because this script
holds a credential to a live payments account and dispute evidence can be sent
exactly once.
"""
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_deadlines")
API = "https://api.stripe.com/v1"
CRITICAL_HOURS = 72.0
# Still waiting on you.
OPEN = ("needs_response", "warning_needs_response")
# Answered; the network has it.
IN_REVIEW = ("under_review", "warning_under_review")
# Finished either way.
SETTLED = ("won", "lost", "warning_closed")
def verdict(dispute, now, critical_hours=CRITICAL_HOURS):
"""Classify one dispute. Pure, so the deadline arithmetic can be tested.
`now` is a unix timestamp. Returns (state, detail).
The states that matter are `critical` (deadline close, nothing sent) and
`staged` (deadline close, evidence written but submission_count still 0),
which is the same loss with the work already paid for.
"""
status = dispute.get("status")
ed = dispute.get("evidence_details") or {}
if status in IN_REVIEW:
return ("submitted", "evidence is in and the network is reviewing it")
if status in SETTLED:
return ("closed", "closed as %s; there is nothing left to send" % status)
if status not in OPEN:
return ("unknown", "unrecognised status %r" % (status,))
due_by = ed.get("due_by")
staged = bool(ed.get("has_evidence"))
sent = ed.get("submission_count") or 0
if ed.get("past_due") or (due_by is not None and due_by <= now):
return ("forfeited",
"past due_by while still needing a response. The funds and the "
"dispute fee are gone, and no evidence will be accepted now.")
if due_by is None:
return ("unknown", "open, but with no due_by to measure against")
hours = (due_by - now) / 3600.0
if hours <= critical_hours:
if staged and not sent:
return ("staged",
"%.1f hour(s) left. Evidence is staged but submission_count "
"is 0, so none of it has reached the network." % hours)
return ("critical", "%.1f hour(s) left and nothing attached." % hours)
if staged and not sent:
return ("open",
"%.1f day(s) left; evidence staged, not submitted" % (hours / 24.0))
return ("open", "%.1f day(s) left to assemble evidence" % (hours / 24.0))
def money(dispute):
"""Amount at risk, in minor units.
Deliberately not divided by 100: that is wrong for zero-decimal currencies
like JPY, and a report that quietly reads 100x low on one currency is worse
than one that makes you read the currency code.
"""
return "%s %s" % (dispute.get("amount"), (dispute.get("currency") or "?").upper())
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 disputes(session, limit):
"""Yield disputes, newest first, up to `limit`."""
seen = 0
params = {"limit": 100}
while True:
page = get(session, "/disputes", params)
data = page.get("data", [])
for d in data:
yield d
seen += 1
if not data or not page.get("has_more") or seen >= limit:
break
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--hours", type=float, default=CRITICAL_HOURS,
help="how close to due_by counts as critical")
ap.add_argument("--max-disputes", type=int, default=1000,
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})
now = time.time()
seen = urgent = 0
for d in disputes(s, args.max_disputes):
seen += 1
state, detail = verdict(d, now, args.hours)
if state in ("submitted", "closed", "open"):
log.info("%-10s %s %s", state, d.get("id", "?"), detail)
continue
urgent += 1
log.warning("%-10s %s %s %s", state, d.get("id", "?"), money(d), detail)
if state == "unknown":
continue
if state == "forfeited":
log.warning(" nothing to run: the window is closed. Count it with the "
"other forfeits and fix the sweep, not this dispute.")
continue
log.warning(" repair: POST %s/disputes/%s "
"-d 'evidence[product_description]=...' "
"-d 'evidence[shipping_tracking_number]=...' "
"-d 'evidence[customer_communication]=<file_id>'",
API, d["id"])
log.warning(" evidence submits once, so assemble it all first. "
"To concede on purpose: POST %s/disputes/%s/close", API, d["id"])
if "visa_compelling_evidence_3" in (d.get("enhanced_eligibility_types") or []):
log.warning(" eligible for Visa Compelling Evidence 3.0: Stripe "
"pre-populates most of this from prior transactions")
log.info("%d dispute(s) read, %d needing a response now", seen, urgent)
return 1 if urgent else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe disputes whose response deadline is about to pass.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Disputes. The response is printed, never submitted, because
* dispute evidence can be sent exactly once.
*/
const API = 'https://api.stripe.com/v1';
export const CRITICAL_HOURS = 72;
const OPEN = ['needs_response', 'warning_needs_response'];
const IN_REVIEW = ['under_review', 'warning_under_review'];
const SETTLED = ['won', 'lost', 'warning_closed'];
/**
* Classify one dispute. Pure, so the deadline arithmetic can be tested.
* `now` is a unix timestamp in seconds.
*/
export function verdict(dispute, now, criticalHours = CRITICAL_HOURS) {
const status = dispute.status;
const ed = dispute.evidence_details ?? {};
if (IN_REVIEW.includes(status)) {
return ['submitted', 'evidence is in and the network is reviewing it'];
}
if (SETTLED.includes(status)) {
return ['closed', `closed as ${status}; there is nothing left to send`];
}
if (!OPEN.includes(status)) {
return ['unknown', `unrecognised status ${JSON.stringify(status)}`];
}
const dueBy = ed.due_by;
const staged = Boolean(ed.has_evidence);
const sent = ed.submission_count ?? 0;
if (ed.past_due || (dueBy !== undefined && dueBy !== null && dueBy <= now)) {
return ['forfeited',
'past due_by while still needing a response. The funds and the dispute ' +
'fee are gone, and no evidence will be accepted now.'];
}
if (dueBy === undefined || dueBy === null) {
return ['unknown', 'open, but with no due_by to measure against'];
}
const hours = (dueBy - now) / 3600;
if (hours <= criticalHours) {
if (staged && !sent) {
return ['staged',
`${hours.toFixed(1)} hour(s) left. Evidence is staged but ` +
'submission_count is 0, so none of it has reached the network.'];
}
return ['critical', `${hours.toFixed(1)} hour(s) left and nothing attached.`];
}
if (staged && !sent) {
return ['open', `${(hours / 24).toFixed(1)} day(s) left; evidence staged, not submitted`];
}
return ['open', `${(hours / 24).toFixed(1)} day(s) left to assemble evidence`];
}
/**
* Amount at risk, in minor units. Not divided by 100, which is wrong for
* zero-decimal currencies such as JPY.
*/
export function money(dispute) {
return `${dispute.amount} ${(dispute.currency ?? '?').toUpperCase()}`;
}
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* disputes(key, limit = 1000) {
let seen = 0;
const params = { limit: 100 };
for (;;) {
const page = await get(key, '/disputes', params);
const data = page.data ?? [];
for (const d of data) { yield d; seen += 1; }
if (data.length === 0 || !page.has_more || seen >= limit) break;
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 now = Date.now() / 1000;
let seen = 0;
let urgent = 0;
for await (const d of disputes(key)) {
seen += 1;
const [state, detail] = verdict(d, now);
if (state === 'submitted' || state === 'closed' || state === 'open') {
console.log(`${state.padEnd(10)} ${d.id ?? '?'} ${detail}`);
continue;
}
urgent += 1;
console.warn(`${state.padEnd(10)} ${d.id ?? '?'} ${money(d)} ${detail}`);
if (state === 'unknown') continue;
if (state === 'forfeited') {
console.warn(' nothing to run: the window is closed. Count it with the ' +
'other forfeits and fix the sweep, not this dispute.');
continue;
}
console.warn(` repair: POST ${API}/disputes/${d.id} ` +
`-d 'evidence[product_description]=...' ` +
`-d 'evidence[shipping_tracking_number]=...' ` +
`-d 'evidence[customer_communication]=<file_id>'`);
console.warn(' evidence submits once, so assemble it all first. ' +
`To concede on purpose: POST ${API}/disputes/${d.id}/close`);
if ((d.enhanced_eligibility_types ?? []).includes('visa_compelling_evidence_3')) {
console.warn(' eligible for Visa Compelling Evidence 3.0: Stripe ' +
'pre-populates most of this from prior transactions');
}
}
console.log(`${seen} dispute(s) read, ${urgent} needing a response now`);
process.exitCode = urgent ? 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
Two cases carry the note. The first is the exact boundary, because a check that flips at 71 hours instead of 72 loses a day of the three you have left. The second is a dispute with evidence staged and submission_count still zero: it looks answered on every field except the one that counts, and treating it as answered is how the work gets done and thrown away.
from stripe_dispute_deadlines import verdict
NOW = 1_700_000_000
def open_dispute(hours_left, **evidence):
ev = {"due_by": NOW + int(hours_left * 3600)}
ev.update(evidence)
return {"id": "du_1", "status": "needs_response", "evidence_details": ev}
def test_deadline_inside_the_window_with_nothing_attached_is_critical():
state, detail = verdict(open_dispute(6), NOW)
assert state == "critical"
assert "6.0" in detail
def test_seventy_two_hours_is_the_boundary_and_it_is_inclusive():
# 72 must already fire. Waiting for 71 spends a third of what is left.
assert verdict(open_dispute(72), NOW)[0] == "critical"
assert verdict(open_dispute(72.1), NOW)[0] == "open"
def test_staged_evidence_that_was_never_submitted_is_its_own_state():
state, detail = verdict(
open_dispute(10, has_evidence=True, submission_count=0), NOW)
assert state == "staged"
assert "submission_count" in detail
def test_past_due_while_still_needing_a_response_is_forfeited():
d = open_dispute(-1, past_due=True)
state, detail = verdict(d, NOW)
assert state == "forfeited"
assert "fee" in detail
def test_under_review_is_answered_and_a_missing_due_by_is_not_silently_open():
assert verdict({"status": "under_review"}, NOW)[0] == "submitted"
assert verdict({"status": "needs_response", "evidence_details": {}}, NOW)[0] == "unknown"
assert verdict({"status": "sleeping"}, NOW)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-dispute-deadlines.mjs';
const NOW = 1_700_000_000;
function openDispute(hoursLeft, evidence = {}) {
return {
id: 'du_1',
status: 'needs_response',
evidence_details: { due_by: NOW + Math.round(hoursLeft * 3600), ...evidence },
};
}
test('deadline inside the window with nothing attached is critical', () => {
const [state, detail] = verdict(openDispute(6), NOW);
assert.equal(state, 'critical');
assert.match(detail, /6\.0 hour/);
});
test('seventy two hours is the boundary and it is inclusive', () => {
assert.equal(verdict(openDispute(72), NOW)[0], 'critical');
assert.equal(verdict(openDispute(72.1), NOW)[0], 'open');
});
test('staged evidence that was never submitted is its own state', () => {
const [state, detail] = verdict(
openDispute(10, { has_evidence: true, submission_count: 0 }), NOW);
assert.equal(state, 'staged');
assert.match(detail, /submission_count/);
});
test('past due while still needing a response is forfeited', () => {
const [state, detail] = verdict(openDispute(-1, { past_due: true }), NOW);
assert.equal(state, 'forfeited');
assert.match(detail, /fee/);
});
test('answered and unreadable disputes are not treated as open', () => {
assert.equal(verdict({ status: 'under_review' }, NOW)[0], 'submitted');
assert.equal(
verdict({ status: 'needs_response', evidence_details: {} }, NOW)[0], 'unknown');
assert.equal(verdict({ status: 'sleeping' }, NOW)[0], 'unknown');
});
FAQ
How long do I actually have to respond to a Stripe dispute?
Roughly 7 to 21 days, set by the card network rather than by Stripe, which is why the only trustworthy number is evidence_details.due_by on the dispute itself. The clock starts when the network files the dispute, not when the notification reaches you, so some of the window is usually gone before anyone reads about it.
What happens if the deadline passes with no response?
You lose automatically. The disputed funds are not retrievable and the dispute fee is not returned. The outcome is recorded as lost, indistinguishable in the Dashboard from a dispute you fought and lost on the evidence.
Does has_evidence being true mean the response was sent?
No, and this is the trap the script exists for. has_evidence goes true as soon as any evidence field is saved. submission_count is the field that says the response reached the network. Staged evidence with a submission count of zero still forfeits at the deadline.
Can I submit evidence twice to add something I forgot?
No. Evidence submits once per dispute, which is why the correct workflow is to assemble everything before sending. It is also why this script prints the submission instead of performing it: an automated partial submission would spend your only attempt.
What is enhanced_eligibility_types for?
It tells you when a dispute qualifies for a network programme such as Visa Compelling Evidence 3.0, where Stripe can pre-populate the response from prior transactions with the same customer. Reading it first tells you whether you are assembling evidence or reviewing evidence Stripe already has.
Related field notes
- Disputes closed as lost were never actually contested
- Radar blocks payments and nobody reads the block reasons
- Refunds sit failed or requires_action and nobody notices
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
- List all disputes — Stripe API reference
- API keys — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.