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: 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.