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.

Evidence submission approaches Manual suits under five disputes a month, templated to thirty, full automation above twenty, and a third party trades a revenue share for zero engineering. Manual high deadline risk inconsistent < 5 / month Templated consistent fields manual submit 5-30 / month Automated reason-code tuned never late > 20 / month Third-party rev-share fee ~20-25% cut no eng capacity
Full automation earns out above ~20 disputes/month β€” it is reason-code-tuned and, crucially, never misses a deadline.
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.

Evidence by reason code A fraudulent dispute pulls authentication and prior-charge evidence; a product-not-received dispute pulls usage logs and access timestamps. Reason code route evidence fraudulent 3DS auth + prior charges product_not_received usage logs + access times
Reason-code routing is the win-rate lever β€” authentication proof wins fraud disputes, usage proof wins service disputes.

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.

Chargeback automation tests Test triggers drive won and lost, a replayed dispute yields one reserve, the submit fires at day 11 of 14, and evidence routes by reason code. Lifecycle test triggers won / lost Idempotent replay dispute one reserve Timing 14-day window submits day 11 Routing by reason right evidence
The 80%-window timing test guards the deadline; the reserve invariant catches any double-count.

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.

Chargeback pitfalls Submitting early locks evidence, files must be Stripe File objects, deadlines vary per dispute, refund-after-dispute double-pays, and win-rate hides the non-refundable fee. Submit early locks evidence → gather then send File format URL not id → Files API Deadline hardcoded 14d → read due_by Refund double-pays → block till closed Win-rate hides fee → net recovery
Five pitfalls β€” the hardcoded deadline and the refund-after-dispute double-pay are the two most expensive.
  • Submitting too early loses you data. Once you call submit: true you cannot add evidence. Gather everything, then submit once β€” do not submit on dispute creation.
  • Uploaded files must be Stripe File objects. service_documentation and receipt fields take file IDs, not URLs. Upload via the Files API with purpose: 'dispute_evidence' first.
  • The deadline is in the dispute, not fixed. Read evidence_details.due_by per 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.