Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 026 / 100

Disputes and chargebacks: a hands-on guide for engineers

Handle them before they cost you.

Expona Admin8 min read

Introduction

When a customer disputes a charge or initiates a chargeback, your revenue is frozen, your reputation takes a hit, and you're suddenly in a legal defense game with your payment processor. Disputes and chargebacks are not edge cases in payment engineering—they happen regularly across e-commerce, SaaS, and subscription platforms. This guide walks you through the practical mechanics of handling them before they cost you.

What happens when a dispute is created

A chargeback typically flows through several stages. A customer contacts their bank claiming they didn't authorize a charge or that the product/service was never delivered. The bank initiates a dispute process, which Stripe detects and broadcasts as a charge.dispute.created webhook event. This is your signal to act.

Understanding the dispute lifecycle is critical. When Stripe receives a chargeback notification from the card network, we get roughly 7–10 days (varies by card network and bank) to submit evidence before the dispute is escalated. If you don't respond, you lose by default and eat the full transaction amount plus chargeback fees—typically $15–$100 per incident.

The most common dispute reasons we see across production systems are:

  • Customer-initiated. Customer claims they didn't authorize the transaction.
  • Service not rendered. Customer says they paid but never received the product or service.
  • Unrecognized transaction. Similar to customer-initiated but often involves fraudulent card use.
  • Billing error. Customer was charged twice or charged an incorrect amount.
  • Refund not received. Customer claims they initiated a refund that never arrived.

Listen for charge.dispute.created

Your system must actively monitor the charge.dispute.created event. This webhook fires the moment Stripe becomes aware of the dispute. If you miss it or ignore it, you're essentially conceding defeat.

Set up a dedicated webhook handler. Here's how we structure it in a typical Node.js/NestJS environment:

typescript
@Post('/webhooks/stripe')
async handleStripeWebhook(@Req() req: Request) {
  const signature = req.headers['stripe-signature'] as string;
  let event: Stripe.Event;

  try {
    event = this.stripe.webhooks.constructEvent(
      req.body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return { received: false };
  }

  if (event.type === 'charge.dispute.created') {
    const dispute = event.data.object as Stripe.Dispute;
    await this.handleDispute(dispute);
  }

  return { received: true };
}

Store the dispute in your database immediately:

typescript
async handleDispute(dispute: Stripe.Dispute) {
  const dbDispute = await this.db.disputes.create({
    stripeDisputeId: dispute.id,
    chargeId: dispute.charge,
    amount: dispute.amount,
    currency: dispute.currency,
    reason: dispute.reason,
    status: dispute.status,
    deadline: new Date(dispute.evidence_details.due_by * 1000),
    createdAt: new Date(),
  });

  console.log(`Dispute created: ${dispute.id}, due by ${dbDispute.deadline}`);
  
  // Trigger internal alert or queue evidence gathering
  await this.queue.add('gather-dispute-evidence', {
    disputeId: dispute.id,
    chargeId: dispute.charge,
  });
}

Set up monitoring on your deadline column. When a dispute is within 48 hours of its deadline and no evidence has been submitted, trigger an alert to your team. Disputes are triage moments—treat them that way.

Submit evidence quickly

Evidence submission is where most teams lose disputes unnecessarily. You have compelling proof—a delivery confirmation, a signed service contract, API logs showing the customer using the service—but you never get it to Stripe. The deadline pressure is real, but rushing evidence submission with missing or weak documentation is worse than being late.

First, categorize your disputes by reason and prepare standard evidence templates:

Dispute Reason Key Evidence Priority
Unauthorized transaction Proof of authentication (IP logs, device fingerprint, email confirmation) High
Service not rendered Delivery logs, usage records, service access timestamps Critical
Billing error Invoice history, refund records, payment confirmation emails High
Unrecognized transaction Authentication proof + customer communication history Critical

For a SaaS product dispute (e.g., customer claims they never accessed the software), we gather:

  • Login timestamps and IP addresses for the customer's account during the subscription period
  • API calls made by their account (sanitized of sensitive data)
  • Email confirmations of signup, payment, and account setup
  • Screenshot of the dashboard or customer portal showing active usage

Submit evidence programmatically to Stripe using the charges.submitEvidence API:

typescript
async submitDisputeEvidence(
  chargeId: string,
  evidenceData: {
    customerCommunication?: string;
    accessLogs?: string;
    invoiceDoc?: Buffer;
    refundPolicy?: string;
  }
) {
  try {
    const evidence: Stripe.ChargeCreateParamsEvidence = {};

    if (evidenceData.customerCommunication) {
      evidence.customer_communication = evidenceData.customerCommunication;
    }

    if (evidenceData.accessLogs) {
      evidence.customer_email_address = evidenceData.accessLogs;
    }

    if (evidenceData.refundPolicy) {
      evidence.refund_policy = evidenceData.refundPolicy;
    }

    // File uploads for receipts, invoices, etc.
    if (evidenceData.invoiceDoc) {
      const uploadedFile = await this.stripe.files.create({
        purpose: 'dispute_evidence',
        file: {
          data: evidenceData.invoiceDoc,
          name: 'invoice.pdf',
        },
      });

      evidence.receipt = uploadedFile.id;
    }

    const updatedDispute = await this.stripe.charges.updateDispute(chargeId, {
      evidence,
    });

    console.log(`Evidence submitted for charge ${chargeId}`);
    return updatedDispute;
  } catch (err) {
    console.error('Failed to submit evidence:', err);
    throw err;
  }
}

Submit evidence within 3–5 days of receiving the webhook, not on day 9. Banks and Stripe reviewers process evidence in batches. Early submission increases your odds of human review during favorable conditions and reduces the chance that supporting documents get lost in the queue.

Clear statement descriptors reduce disputes

A significant percentage of disputes stem from customer confusion. They see a charge on their statement from a company name they don't recognize and immediately dispute it. This is preventable.

Stripe statement descriptors control what appears on the customer's credit card statement. By default, your Stripe account name appears. This should be your company name, not "S1234567 *STRIPE". Configure your descriptor in the Stripe dashboard or programmatically:

typescript
// Set at account level
await this.stripe.account.update({
  statement_descriptor: 'ACME Software Inc',
});

// Override per charge
await this.stripe.charges.create({
  amount: 9999,
  currency: 'usd',
  customer: customerId,
  description: 'Monthly subscription',
  statement_descriptor: 'ACME SOFTWARE', // Max 22 chars
});

Best practices for descriptors:

  • Keep it under 15 characters. Brevity reduces confusion and increases brand recognition.
  • Use your company name or product name, not a generic term like "Payment" or "Charge".
  • Avoid special characters except hyphens and spaces.
  • For SaaS recurring charges, append a month or identifier: ACME SOFTWARE #1 or ACME APP-JAN.
  • Test the descriptor by processing a test charge and viewing it in your own bank statement mockup.

We've seen dispute rates drop by 25–40% after switching from a generic descriptor to a recognizable brand name. Customers are less likely to dispute charges they recognize.

Track dispute rates

You cannot manage what you don't measure. Establish a dashboard that tracks:

  • Dispute count and rate. Total disputes per month divided by total transactions. Industry benchmarks range from 0.05% to 0.5% depending on your vertical.
  • Win rate. Disputes won (reversed) vs. disputes lost. Aim for 70%+ if you're submitting evidence properly.
  • Dispute reason breakdown. Which categories dominate? Track trends over time.
  • Time to evidence submission. How long does your team take to gather and submit evidence after a dispute is created?
  • Chargeback fees incurred. Each lost dispute costs you the transaction amount plus bank and processor fees.

Query your disputes table and Stripe's API to build these metrics:

typescript
async getDisputeMetrics(startDate: Date, endDate: Date) {
  const disputes = await this.db.disputes.findMany({
    where: {
      createdAt: { gte: startDate, lte: endDate },
    },
  });

  const totalDisputes = disputes.length;
  const wonDisputes = disputes.filter(d => d.status === 'won').length;
  const lostDisputes = disputes.filter(d => d.status === 'lost').length;
  
  const reasonBreakdown = disputes.reduce((acc, d) => {
    acc[d.reason] = (acc[d.reason] || 0) + 1;
    return acc;
  }, {} as Record);

  const avgTimeToSubmit = disputes
    .filter(d => d.submittedAt)
    .reduce((sum, d) => sum + (d.submittedAt.getTime() - d.createdAt.getTime()), 0) /
    disputes.filter(d => d.submittedAt).length;

  return {
    totalDisputes,
    wonDisputes,
    lostDisputes,
    winRate: totalDisputes > 0 ? (wonDisputes / totalDisputes) * 100 : 0,
    reasonBreakdown,
    avgTimeToSubmitMs: avgTimeToSubmit,
    chargebackFeesIncurred: lostDisputes * 25, // Example: $25 per chargeback
  };
}

Review metrics weekly. If your win rate drops below 60%, investigate whether evidence quality or timing has degraded. If a specific dispute reason spikes, it signals a product or communication issue that needs fixing at the source.

Common mistakes

Missing webhooks. Not all disputes reach your system if webhooks are misconfigured or your endpoint is flaky. Validate webhook delivery in the Stripe dashboard. Set up retry logic for failed deliveries.

Submitting weak evidence. A vague message saying "customer used the service" won't convince a bank. Provide specifics: exact login timestamps, API call counts, file downloads, or payment confirmations. Treat evidence like you're defending yourself in court.

Ignoring repeat customers. If the same customer disputes frequently, they're either unreliable or there's a product/communication problem. Flag repeat disputes and consider requiring stronger verification for future transactions or declining them outright.

Not following up. After submitting evidence, track the dispute status. Stripe updates the charge.dispute.updated webhook when a decision is made. Some disputes take weeks to resolve. Automate follow-up queries if a dispute sits in "under_review" beyond 14 days.

Forgetting to refund legitimate disputes. Sometimes the customer is right. They ordered something they didn't receive, or there was a genuine billing error. Refunding immediately reduces escalation and builds trust. The cost of a refund is lower than the cost of a chargeback fee and operational overhead.

Key takeaways

  • Monitor charge.dispute.created webhooks and store every dispute in your database with its deadline prominently tracked.
  • Gather and submit evidence within 3–5 days of receiving a dispute, not in the last 48 hours. Quality and timing both matter.
  • Keep statement descriptors short, recognizable, and branded. This alone can cut disputes by 25%+ as customers stop disputing charges they recognize.
  • Track dispute metrics weekly: count, win rate, reason breakdown, and chargeback fees. Treat disputes as a product health signal, not just a compliance burden.
  • Automate dispute workflow: detection → alerting → evidence gathering → submission → status tracking. Manual handling guarantees you'll miss deadlines.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles