Automating Chargeback Dispute Evidence Submission
You face this the first time a chargeback lands and someone on the team copies receipts into a dashboard by hand at 11pm because the evidence deadline is tomorrow. Manual evidence submission does not scale: deadlines are hard, the data lives in five systems, and a missed window is an automatic loss. This guide automates the gather-and-submit loop for Stripe disputes. It builds directly on the dispute lifecycle and reserve mechanics in Fraud Prevention & Dispute Management; read that first if the needs_response β under_review β won/lost state machine is unfamiliar.
The goal is a system where a charge.dispute.created webhook triggers an idempotent job that pulls the right evidence for the disputeβs reason code, fills a template, submits before the deadline, and holds the disputed funds in a reserve until the outcome is known.
Trade-offs
The approaches scale with dispute volume: manual works below ~5/month, templating helps to ~30, full automation is worth it above ~20, and a third party trades a rev-share for zero engineering. The map ranks them by volume fit.
| Approach | Engineering effort | Win-rate impact | Deadline risk | Best when |
|---|---|---|---|---|
| Fully manual | None upfront, high ongoing | Low β inconsistent evidence | High β human-dependent | < 5 disputes/month |
| Templated, manual submit | ~2 days | Medium β consistent fields | Medium | 5β30 disputes/month |
| Fully automated (this guide) | ~1 week | High β reason-code-tuned evidence, never late | Low β scheduled at 80% of window | > 20 disputes/month or growing |
| Third-party (Chargeflow / Justt) | Integration only; rev-share fee | High, but ~20β25% of recovered funds | Low | No engineering capacity, high volume |
The volume thresholds are not arbitrary. Below five disputes a month the fixed cost of building anything outweighs the loss you would recover β even at a pessimistic 30% win rate, five disputes on an average B2B invoice of 4900 in minor units returns less than the time to wire up a Files API upload path. The templated tier earns its keep once the same three reason codes recur, because the marginal cost of a new dispute drops to filling a form the worker already knows how to populate. Full automation only pays back the extra week of engineering when you are also paying the network dispute fee often enough β typically 1500 minor units per dispute, non-refundable β that a single missed deadline erases a month of otherwise-winnable cases.
Where the third-party economics break even
A rev-share provider quotes a percentage of recovered funds, which sounds free until you model it against your own win rate. If you already win 45% of fraudulent disputes with 3DS2 evidence, handing those to a provider that takes 22% of recoveries is pure margin leakage β you are paying them to submit evidence you already have. The provider only wins when it beats your baseline recovery by more than its cut. The pragmatic split most teams land on is a hybrid: automate the reason codes where your evidence is strong and deterministic (fraudulent with an authenticated 3DS outcome, product_not_received with dense usage logs), and route the messy service-quality and subscription_canceled reasons β where evidence is subjective and win rates hover near 20% β to a provider whose whole business is arguing those. Track the two populations separately or the blended win rate will lie to you about which decision was correct.
Representment versus pre-arbitration
Automating the first response is only half the lifecycle. When you win the initial representment, the issuing bank can escalate to pre-arbitration, and the deadlines there are shorter and unforgiving β often 7 to 10 days rather than the 14 to 21 of the first round. Design the reserve so it does not release on the first won; a dispute can reopen. Keep the dispute_reserve entry live until the networkβs final liability window has passed, and treat charge.dispute.closed with status = 'won' as provisional for the reason codes that historically get re-escalated. The alternative β releasing funds on the first win and then eating a silent reversal three weeks later β shows up as an unexplained negative in the next monthβs reconciliation and is painful to trace back to a specific dispute_id.
Step-by-Step Implementation
The four steps run on the dispute clock: open a reserve on creation, assemble reason-code-specific evidence, submit at 80% of the window, then reconcile on close. The reason-code routing is the win-rate lever β the diagram shows how different reasons pull different evidence.
1. Open the reserve on dispute creation
The webhook handler must do two things atomically: record the dispute and move the funds into a reserve. Keying the reserve entry on dispute_id makes a redelivered webhook a no-op.
import Stripe from 'stripe';
async function onDisputeCreated(dispute: Stripe.Dispute) {
await db.transaction(async (tx) => {
const inserted = await tx.disputes.upsert({
disputeId: dispute.id,
chargeId: dispute.charge as string,
reasonCode: dispute.reason, // e.g. 'fraudulent', 'product_not_received'
amountMinor: dispute.amount, // cents
currency: dispute.currency,
evidenceDueBy: dispute.evidence_details?.due_by, // unix seconds
status: 'needs_response',
});
if (inserted.isNew) {
await tx.ledger.post({ disputeId: dispute.id, debit: 'revenue_recognized',
credit: 'dispute_reserve', amountMinor: dispute.amount, currency: dispute.currency,
reason: 'chargeback_opened' }); // β οΈ funds held, not lost yet
}
});
await queue.add('assemble-evidence', { disputeId: dispute.id });
}
The transaction boundary matters more than it looks. If the ledger post and the dispute upsert are not in the same transaction, a crash between them leaves you with a recorded dispute and no reserve, or a reserve with no dispute row to reconcile against β both corrupt the invariant you will assert in testing. Keying the reserve on dispute_id rather than charge_id is deliberate too: a single charge can, in rare card-network scenarios, generate a second dispute after the first is withdrawn, and you want each to hold its own reserve rather than silently coalescing. Note that dispute.charge arrives as either a string or an expanded object depending on your API version and webhook expansion settings; coercing it with as string is only safe if you have pinned the API version and confirmed the charge is not expanded, otherwise read typeof dispute.charge === 'string' ? dispute.charge : dispute.charge.id.
One subtlety in the reserve entry: you debit revenue_recognized and credit dispute_reserve, which pulls the money out of recognized revenue but does not yet touch cash. The processor has already provisionally debited your balance the moment the dispute opened, so your bank-side cash is down before your ledger reflects it. Reconciling the two requires a separate cash-in-transit account; do not conflate the accounting reserve with the actual balance hold or your books will disagree with the Stripe balance transaction by exactly the disputed amount for the life of every open dispute.
2. Assemble evidence by reason code
Different reason codes win on different evidence. A fraudulent dispute wins on authentication and prior undisputed charges; a product_not_received dispute wins on usage logs and access timestamps. Map the reason to a template so the worker pulls only relevant fields.
type EvidenceBundle = Partial<Stripe.Dispute.EvidenceParams>;
async function buildEvidence(disputeId: string): Promise<EvidenceBundle> {
const d = await db.disputes.get(disputeId);
const order = await db.orders.byCharge(d.chargeId);
const risk = await db.riskAssessment.byCharge(d.chargeId);
const base: EvidenceBundle = {
customer_name: order.customerName,
customer_email_address: order.email,
billing_address: order.billingAddress,
};
if (d.reasonCode === 'fraudulent') {
return {
...base,
// Authentication proof is the strongest evidence for fraud disputes.
uncategorized_text: risk.threeDsOutcome === 'authenticated'
? `Charge was 3DS2 authenticated; liability shifted. Outcome: ${risk.threeDsOutcome}.`
: `Risk score ${risk.score}/99, level ${risk.level}. Prior undisputed charges: ${order.priorCharges}.`,
};
}
// product_not_received / subscription_canceled β usage proof
const usage = await db.usageLogs.forCustomer(order.customerId, d.chargeId);
return {
...base,
service_documentation: usage.accessLogFileId, // uploaded file id
uncategorized_text: `Last login ${usage.lastLoginAt}; ${usage.sessionCount} sessions after charge.`,
};
}
The uncategorized_text field is a blunt instrument β reviewers at the issuing bank skim it β so lead with the single strongest fact rather than a wall of context. For a fraudulent dispute the liability shift from an authenticated 3DS2 outcome is dispositive, so it goes first and nothing else is strictly needed; for a soft-fraud case where 3DS was frictionless or bypassed, the compelling-evidence pattern is prior undisputed charges from the same customer_id on the same card, which demonstrates a real relationship rather than a stolen credential. Populate product_description, shipping_documentation, and duration fields only when they apply β Stripe ignores empty ones, but a half-filled template reads as boilerplate and reviewers discount it.
Assembling evidence is where most of the latency and most of the flakiness live, because you are fanning out to the order system, the risk service, and the usage store, any of which can be slow or briefly unavailable. Build the bundle idempotently and cache it against dispute_id so a retry does not re-query five systems. If one source is unreachable, submit the partial bundle you can assemble rather than blocking β a fraud dispute with only the 3DS outcome and no billing address still wins more often than an empty response, and an empty response is what you get if you let a usage-log timeout cascade into a missed deadline.
3. Submit before the deadline
Submission is a single Stripe call, but the timing is the whole point. Schedule the submit at 80% of the window so a transient API failure still leaves room to retry. Submitting transitions the dispute to under_review.
async function submitEvidence(disputeId: string) {
const evidence = await buildEvidence(disputeId);
await stripe.disputes.update(disputeId, { evidence, submit: true }); // β
moves to under_review
await db.disputes.update(disputeId, { status: 'under_review', submittedAt: new Date() });
}
// Scheduler: enqueue the submit job at 80% of the remaining window.
function scheduleSubmission(dueBySeconds: number) {
const nowMs = Date.now();
const windowMs = dueBySeconds * 1000 - nowMs;
const fireAt = nowMs + Math.floor(windowMs * 0.8); // β οΈ leaves 20% buffer for retries
return queue.add('submit-evidence', {}, { delay: Math.max(fireAt - nowMs, 0) });
}
The 80% figure is a tuned buffer, not a magic constant. On a 14-day window it leaves roughly 2.8 days for retries, which comfortably absorbs a Stripe API incident plus a re-assembly of the evidence bundle. On a short 7-day window it collapses to about 1.4 days, which is why you compute it from the actual due_by rather than assuming days. Two failure modes push you to submit even earlier: if your evidence sources are batch-updated (a usage rollup that only lands nightly), fire after the rollup you need is guaranteed present; and if the queue itself can lose a delayed job across a deploy, persist the intended fireAt on the dispute row and have a sweeper re-enqueue any dispute whose submit time has passed while it is still needs_response. The scheduled delay in the queue is an optimization; the durable deadline lives in the database.
Guard the submit call for idempotency at the Stripe layer as well. Passing an idempotency_key derived from the dispute_id and a submit attempt counter prevents a retried job from double-submitting, which would otherwise error once the dispute is already under_review. Treat the βalready submittedβ error as success in the worker so a redelivered job does not park in a dead-letter queue and page someone at 3am for a dispute that was, in fact, handled correctly.
4. Reconcile on close and track win-rate
On charge.dispute.closed, resolve the reserve and record the outcome for win-rate analytics. A won dispute releases the reserve; a lost dispute realizes the loss.
-- Win-rate by reason code over the last 90 days.
SELECT reason_code,
COUNT(*) FILTER (WHERE status = 'won') AS won,
COUNT(*) FILTER (WHERE status = 'lost') AS lost,
ROUND(COUNT(*) FILTER (WHERE status = 'won')::numeric
/ NULLIF(COUNT(*) FILTER (WHERE status IN ('won','lost')), 0), 3) AS win_rate
FROM disputes
WHERE closed_at > now() - INTERVAL '90 days'
GROUP BY reason_code
ORDER BY win_rate DESC;
The close handler mirrors the creation handler and must be equally atomic. On won, reverse the reserve entry β debit dispute_reserve, credit revenue_recognized β and record the recovery. On lost, move the reserve to a realized-loss account and post the non-refundable fee separately so it does not distort the recovered amount. Reading dispute.status alone is not enough: Stripe also emits charge.dispute.updated events as the dispute moves through under_review and won/lost, and you want to reconcile the reserve exactly once, on the terminal transition. Guard the reconcile with the same dispute_id idempotency you used at creation, and assert the current stored status before transitioning so an out-of-order updated event cannot flip a closed dispute back to under_review.
Win-rate by reason code is the query that should drive next quarterβs roadmap. If fraudulent disputes win at 0.62 but product_not_received languishes at 0.24, the lever is not more automation β it is denser usage instrumentation so the service-documentation evidence is stronger. Slice the same query by whether 3DS2 was present on the original charge and the story usually sharpens further: authenticated charges that still get disputed are frequently friendly fraud you should win on liability shift, and a low win rate there points at a bug in how you are surfacing the threeDsOutcome, not at a hard case.
Verification & Testing
The tests drive the lifecycle via test triggers, prove reserve idempotency, verify the 80%-window submit timing, and assert reason-code routing plus the reserve invariant. The panel lists them.
Drive the full lifecycle with Stripe test triggers: create a test dispute, assert one reserve entry exists, then assert a redelivered charge.dispute.created produces no second entry. Use a mock clock to assert the submit job fires at 80% of the window for a dispute due in 14 days (β day 11). Assert buildEvidence returns authentication text for a fraudulent reason and usage text for product_not_received. After close, assert the ledger invariant SUM(dispute_reserve WHERE status='needs_response' OR 'under_review') equals the total of all open disputes. Add an integration test that forges an invalid webhook signature and asserts the handler rejects it before writing.
Gotchas & Production Pitfalls
The pitfalls are submitting too early, wrong file formats, hardcoded deadlines, refund-after-dispute double-pays, and confusing win-rate with net recovery. The map groups them.
- Submitting too early loses you data. Once you call
submit: trueyou cannot add evidence. Gather everything, then submit once β do not submit on dispute creation. - Uploaded files must be Stripe File objects.
service_documentationandreceiptfields take file IDs, not URLs. Upload via the Files API withpurpose: 'dispute_evidence'first. - The deadline is in the dispute, not fixed. Read
evidence_details.due_byper dispute; it varies by network and reason code. Hardcoding 14 days will silently miss short windows. - Refunding after a dispute opens double-pays. Once a dispute exists, accept or fight it β a refund on top of a lost dispute means you pay twice. Block refunds while
status != closed. - Win-rate hides the fee. You pay a non-refundable dispute fee win or lose. Track net recovery (recovered amount minus fees), not raw win-rate, when deciding which reason codes are worth fighting.
Frequently Asked Questions
How much evidence is too much? Enough to answer the specific dispute reason and no more. A submission padded with irrelevant logs is harder for a reviewer to assess than a focused one, and reviewers work under time pressure.
Should every dispute be contested? No. Contesting a dispute where the customer is plainly right costs a fee and staff time for no benefit. Set a value threshold and a reason-code filter, and accept the rest quickly so the fee is the only cost.
What is the most useful single piece of evidence? Usually proof of service delivery tied to the disputed period β login timestamps, API calls, or activity records for that account. It directly contradicts the most common consumer dispute reason.
Does winning a dispute recover the fee? Generally not. The dispute fee is charged regardless of outcome in most schemes, which is why prevention and quick acceptance of hopeless cases both matter more than the win rate alone.