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
| 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 |
Step-by-Step Implementation
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 });
}
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.`,
};
}
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) });
}
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;
Verification & Testing
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
- 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.