/stripe-guides

How to handle chargebacks in Stripe?

Learn how to handle Stripe chargebacks step-by-step: set up notifications, prevent disputes, gather evidence, submit responses, and automate your dispute process.

Matt Graham, CEO of Rapid Developers

Book a call with an Expert

Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.

Book a free consultation

How to handle chargebacks in Stripe?

How to Handle Chargebacks in Stripe: A Comprehensive Tutorial

 

Introduction

 

Chargebacks occur when customers dispute transactions with their card issuer rather than requesting a refund directly from you. In Stripe, these appear as "disputes" and require careful handling to maximize your chances of winning. This tutorial will guide you through the complete process of managing chargebacks in Stripe, from prevention to resolution.

 

Step 1: Understanding Stripe's Dispute Process

 

Before diving into handling chargebacks, it's important to understand how Stripe processes disputes:

  • When a customer initiates a chargeback with their bank, Stripe creates a "dispute" object in your account.
  • Stripe automatically withdraws the disputed amount plus a $15 dispute fee from your account.
  • You have a limited time (usually 7-21 days depending on the card network) to submit evidence.
  • After evidence submission, the card network makes a decision, which can take 60-75 days.

 

Step 2: Setting Up Dispute Notifications

 

First, ensure you're promptly notified when disputes occur:

  • Navigate to your Stripe Dashboard → Settings → Notifications
  • Enable email notifications for "Dispute created" events
  • For automated handling, set up webhook endpoints to receive dispute events

Here's how to set up a webhook endpoint to listen for dispute events:


const express = require('express');
const app = express();
const stripe = require('stripe')('sk_test_yourSecretKey');

app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      'whsec\_yourWebhookSecret'
    );
  } catch (err) {
    console.error(`Webhook Error: ${err.message}`);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the dispute.created event
  if (event.type === 'dispute.created') {
    const dispute = event.data.object;
    console.log(`New dispute received: ${dispute.id} for charge ${dispute.charge}`);
    
    // Start your dispute handling process
    handleNewDispute(dispute);
  }

  res.status(200).send({received: true});
});

function handleNewDispute(dispute) {
  // Logic to gather evidence, notify team members, etc.
  console.log(`Dispute amount: ${dispute.amount}, reason: ${dispute.reason}`);
  // Further processing code...
}

app.listen(3000, () => console.log('Running on port 3000'));

 

Step 3: Implementing Chargeback Prevention Measures

 

Prevention is better than cure. Implement these preventive measures:

  • Use Stripe Radar for fraud detection
  • Implement 3D Secure for high-risk transactions
  • Ensure clear billing descriptors that customers will recognize
  • Maintain clear refund policies and responsive customer service

To enable 3D Secure for charges:


// On the server
const paymentIntent = await stripe.paymentIntents.create({
  amount: 1000,
  currency: 'usd',
  payment_method_types: ['card'],
  payment_method: 'pm_card\_threeDSecure2Required',
  confirm: true,
  return\_url: 'https://yourwebsite.com/return-url',
});

// On the client-side (using Stripe.js)
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
  payment\_method: {
    card: elements.getElement('card'),
    billing\_details: {
      name: 'Jenny Rosen',
      email: '[email protected]'
    },
  },
});

 

Step 4: Accessing and Reviewing Disputes

 

When a dispute occurs:

  • Access all disputes via Stripe Dashboard → Payments → Disputes
  • Click on a specific dispute to view details including reason, amount, and deadline
  • Via API, retrieve dispute information using the following code:

const stripe = require('stripe')('sk_test_yourSecretKey');

// Retrieve a specific dispute
async function getDisputeDetails(disputeId) {
  try {
    const dispute = await stripe.disputes.retrieve(disputeId);
    console.log(`Dispute details:`, dispute);
    return dispute;
  } catch (error) {
    console.error(`Error retrieving dispute: ${error.message}`);
  }
}

// List all disputes
async function listAllDisputes() {
  try {
    const disputes = await stripe.disputes.list({
      limit: 100,
      status: 'needs\_response' // Filter by status
    });
    console.log(`Found ${disputes.data.length} disputes that need response`);
    return disputes;
  } catch (error) {
    console.error(`Error listing disputes: ${error.message}`);
  }
}

// Example usage
getDisputeDetails('dp\_1ABCxyz123456');
listAllDisputes();

 

Step 5: Gathering Evidence for Dispute Response

 

Collect appropriate evidence based on the dispute reason:

  • For "product not received": Shipping information, delivery confirmation
  • For "product unacceptable": Product description, photos, customer communications
  • For "fraudulent": Customer details, IP logs, device information, proof of prior transactions
  • For "duplicate": Documentation showing the transactions were different
  • For "subscription canceled": Proof of terms, cancellation policy, communications

Create a centralized evidence repository function:


async function collectDisputeEvidence(charge_id, dispute_id) {
  // Retrieve related charge data
  const charge = await stripe.charges.retrieve(charge\_id);
  
  // Retrieve order details from your database
  const order = await database.getOrderByPaymentId(charge\_id);
  
  // Retrieve customer data
  const customer = await database.getCustomer(order.customer\_id);
  
  // Gather shipping information if applicable
  const shipping = await database.getShippingDetails(order.id);
  
  // Collect communication history
  const communications = await database.getCustomerCommunications(order.customer\_id);
  
  // Format evidence based on dispute reason
  return {
    customer\_name: customer.name,
    customer\_email: customer.email,
    customer_purchase_ip: order.ip\_address,
    shipping_address: shipping.formatted_address,
    shipping_date: shipping.ship_date,
    shipping_tracking_number: shipping.tracking\_number,
    shipping\_carrier: shipping.carrier,
    product\_description: order.items.map(item => item.description).join(', '),
    receipt_or_invoice_id: order.invoice_number,
    customer_signature: shipping.delivery_signature,
    service_date: order.service_date || order.created\_at,
    // Additional evidence fields based on dispute reason...
  };
}

 

Step 6: Submitting Evidence to Stripe

 

Submit your collected evidence through Stripe's Dashboard or API:

  • Via Dashboard: Navigate to the dispute, click "Submit Evidence", fill in the forms
  • Via API: Use the disputes.update method

Here's how to submit evidence via API:


const stripe = require('stripe')('sk_test_yourSecretKey');

async function submitDisputeEvidence(disputeId, evidence) {
  try {
    const dispute = await stripe.disputes.update(
      disputeId,
      {
        evidence: {
          customer_name: evidence.customer_name,
          customer_email_address: evidence.customer\_email,
          customer_purchase_ip: evidence.customer_purchase_ip,
          customer_signature: evidence.customer_signature,
          billing_address: evidence.billing_address,
          receipt: evidence.receipt_or_invoice\_id,
          product_description: evidence.product_description,
          shipping_address: evidence.shipping_address,
          shipping_date: evidence.shipping_date,
          shipping_tracking_number: evidence.shipping_tracking_number,
          shipping_carrier: evidence.shipping_carrier,
          service_date: evidence.service_date,
          duplicate_charge_id: evidence.duplicate_charge_id,
          duplicate_charge_documentation: evidence.duplicate_charge_documentation,
          cancellation_policy: evidence.cancellation_policy,
          cancellation_policy_disclosure: evidence.cancellation_policy_disclosure,
          cancellation_rebuttal: evidence.cancellation_rebuttal,
          refund_policy: evidence.refund_policy,
          refund_policy_disclosure: evidence.refund_policy_disclosure,
          refund_refusal_explanation: evidence.refund_refusal_explanation,
          access_activity_log: evidence.access_activity_log,
          // Additional evidence fields...
        },
        submit: true // Set to false if you want to save without submitting
      }
    );
    
    console.log(`Evidence submitted for dispute ${disputeId}`);
    return dispute;
  } catch (error) {
    console.error(`Error submitting dispute evidence: ${error.message}`);
    throw error;
  }
}

// Example usage
const evidence = await collectDisputeEvidence('ch_1234567890', 'dp_1ABCxyz123456');
submitDisputeEvidence('dp\_1ABCxyz123456', evidence);

 

Step 7: Tracking Dispute Status

 

After submitting evidence, track the dispute status:

  • 'warning_needs_response': You need to submit evidence
  • 'warning_under_review': Evidence submitted, awaiting decision
  • 'warning\_closed': Dispute has been resolved (won or lost)
  • 'won': You won the dispute and received the funds back
  • 'lost': You lost the dispute

Set up a status monitoring system:


const stripe = require('stripe')('sk_test_yourSecretKey');

// Set up a webhook handler for dispute status updates
app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      'whsec\_yourWebhookSecret'
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle dispute status updates
  if (event.type === 'dispute.updated') {
    const dispute = event.data.object;
    await updateDisputeStatus(dispute);
  } else if (event.type === 'dispute.closed') {
    const dispute = event.data.object;
    await finalizeDisputeOutcome(dispute);
  }

  res.status(200).send({received: true});
});

async function updateDisputeStatus(dispute) {
  // Update dispute status in your database
  await database.updateDisputeStatus(dispute.id, dispute.status);
  
  // Notify relevant team members
  if (dispute.status === 'warning_under_review') {
    await notifyTeam(`Dispute ${dispute.id} is now under review by the card network.`);
  }
}

async function finalizeDisputeOutcome(dispute) {
  // Record the final outcome
  await database.updateDisputeOutcome(dispute.id, dispute.status);
  
  // Notify team and take appropriate actions
  if (dispute.status === 'lost') {
    await notifyTeam(`Dispute ${dispute.id} was lost. Amount: ${dispute.amount/100} ${dispute.currency}`);
    // Update financials, analyze why the dispute was lost
    await updateFinancials(dispute.id, dispute.amount, 'lost');
  } else if (dispute.status === 'won') {
    await notifyTeam(`Dispute ${dispute.id} was won! Amount recovered: ${dispute.amount/100} ${dispute.currency}`);
    await updateFinancials(dispute.id, dispute.amount, 'won');
  }
}

 

Step 8: Implementing a Dispute Analytics System

 

Analyze dispute patterns to improve prevention:


async function analyzeDisputes() {
  try {
    // Get disputes from the last 90 days
    const endDate = Math.floor(Date.now() / 1000);
    const startDate = endDate - (90 _ 24 _ 60 \* 60);
    
    const disputes = await stripe.disputes.list({
      created: {
        gte: startDate,
        lte: endDate
      },
      limit: 100,
      expand: ['data.charge']
    });
    
    // Categorize disputes by reason
    const reasonCounts = {};
    const outcomeByReason = {};
    let totalAmount = 0;
    let lostAmount = 0;
    
    disputes.data.forEach(dispute => {
      // Count by reason
      reasonCounts[dispute.reason] = (reasonCounts[dispute.reason] || 0) + 1;
      
      // Track outcomes by reason
      if (!outcomeByReason[dispute.reason]) {
        outcomeByReason[dispute.reason] = { won: 0, lost: 0, pending: 0 };
      }
      
      if (dispute.status === 'won') {
        outcomeByReason[dispute.reason].won += 1;
      } else if (dispute.status === 'lost') {
        outcomeByReason[dispute.reason].lost += 1;
        lostAmount += dispute.amount;
      } else {
        outcomeByReason[dispute.reason].pending += 1;
      }
      
      totalAmount += dispute.amount;
    });
    
    // Calculate dispute rate
    const charges = await stripe.charges.list({
      created: {
        gte: startDate,
        lte: endDate
      },
      limit: 1
    });
    const totalChargeCount = charges.total\_count;
    const disputeRate = (disputes.data.length / totalChargeCount) \* 100;
    
    return {
      total\_disputes: disputes.data.length,
      dispute\_rate: disputeRate.toFixed(2) + '%',
      total_disputed_amount: totalAmount / 100,
      total_lost_amount: lostAmount / 100,
      reasons: reasonCounts,
      outcomes_by_reason: outcomeByReason
    };
  } catch (error) {
    console.error(`Error analyzing disputes: ${error.message}`);
  }
}

 

Step 9: Automating the Dispute Response Process

 

For scale, automate your dispute handling:


const stripe = require('stripe')('sk_test_yourSecretKey');
const express = require('express');
const app = express();

// Automated dispute handling system
class DisputeHandler {
  constructor(database, notificationService) {
    this.database = database;
    this.notifications = notificationService;
  }
  
  async processNewDispute(dispute) {
    try {
      // Log the dispute
      await this.database.logDispute(dispute);
      
      // Notify team
      await this.notifications.alertTeam(dispute);
      
      // Determine dispute category and required evidence
      const evidenceRequirements = this.determineEvidenceRequirements(dispute.reason);
      
      // Gather available evidence automatically
      const evidence = await this.gatherEvidence(dispute.charge, evidenceRequirements);
      
      // Determine if we can automatically respond or need human review
      if (this.canAutoRespond(evidence, evidenceRequirements)) {
        // Submit evidence automatically
        await this.submitEvidence(dispute.id, evidence);
        await this.database.updateDisputeStatus(dispute.id, 'auto\_responded');
        await this.notifications.notifyAutoResponse(dispute.id);
      } else {
        // Flag for human review
        await this.database.updateDisputeStatus(dispute.id, 'needs\_review');
        await this.notifications.requestHumanReview(dispute.id, evidenceRequirements);
      }
      
      return { success: true, status: 'processed' };
    } catch (error) {
      console.error(`Error processing dispute ${dispute.id}: ${error.message}`);
      await this.notifications.reportError(dispute.id, error);
      return { success: false, error: error.message };
    }
  }
  
  determineEvidenceRequirements(reason) {
    const evidenceMap = {
      'fraudulent': ['customer_details', 'ip_logs', 'device_info', 'prior_transactions'],
      'product_not_received': ['shipping_info', 'tracking_number', 'delivery\_confirmation'],
      'product_unacceptable': ['product_description', 'photos', 'communications'],
      'duplicate': ['transaction_comparison', 'order_details'],
      'subscription_canceled': ['terms_acceptance', 'cancellation_policy', 'usage_logs'],
      'unrecognized': ['transaction_receipt', 'business_details'],
      'credit_not_processed': ['refund_policy', 'communications', 'cancellation_records'],
      'general': ['receipt', 'customer\_info', 'communications']
    };
    
    return evidenceMap[reason] || evidenceMap['general'];
  }
  
  async gatherEvidence(chargeId, requiredEvidence) {
    // Get charge details
    const charge = await stripe.charges.retrieve(chargeId, {
      expand: ['customer', 'payment\_method']
    });
    
    // Get order from your database
    const order = await this.database.getOrderByChargeId(chargeId);
    
    // Build evidence object based on requirements
    const evidence = {};
    
    for (const item of requiredEvidence) {
      switch(item) {
        case 'customer\_details':
          evidence.customer_name = order?.customer?.name || charge.billing_details?.name;
          evidence.customer_email_address = order?.customer?.email || charge.billing\_details?.email;
          evidence.billing_address = JSON.stringify(charge.billing_details?.address);
          break;
        case 'ip\_logs':
          evidence.customer_purchase_ip = order?.ip\_address;
          break;
        case 'shipping\_info':
          if (order?.shipping) {
            evidence.shipping\_address = JSON.stringify(order.shipping.address);
            evidence.shipping_date = order.shipping.ship_date;
            evidence.shipping\_carrier = order.shipping.carrier;
            evidence.shipping_tracking_number = order.shipping.tracking\_number;
          }
          break;
        // Add more evidence gathering logic for each category
      }
    }
    
    return evidence;
  }
  
  canAutoRespond(evidence, requirements) {
    // Check if we have all critical evidence pieces
    const criticalEvidence = requirements.filter(item => 
      ['shipping_info', 'tracking_number', 'customer\_details'].includes(item));
    
    // Check if all critical evidence is available
    return criticalEvidence.every(item => {
      switch(item) {
        case 'shipping\_info':
          return evidence.shipping_address && evidence.shipping_date;
        case 'tracking\_number':
          return evidence.shipping_tracking_number;
        case 'customer\_details':
          return evidence.customer_name && evidence.customer_email\_address;
        default:
          return false;
      }
    });
  }
  
  async submitEvidence(disputeId, evidence) {
    return await stripe.disputes.update(
      disputeId,
      {
        evidence: evidence,
        submit: true
      }
    );
  }
}

// Set up webhook for automatic processing
app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, 'whsec\_yourWebhookSecret');
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'dispute.created') {
    const disputeHandler = new DisputeHandler(database, notificationService);
    await disputeHandler.processNewDispute(event.data.object);
  }

  res.status(200).send({received: true});
});

 

Step 10: Learning from Dispute Outcomes

 

Continuously improve your dispute handling:

  • Regularly analyze won vs. lost disputes
  • Identify patterns in customer disputes
  • Adjust your business practices based on insights
  • Update your evidence templates based on successful cases

Implement a feedback loop:


async function disputeLearningSystem() {
  // Get recent dispute outcomes
  const recentDisputes = await stripe.disputes.list({
    created: {
      gte: Math.floor(Date.now() / 1000) - (30 _ 24 _ 60 \* 60) // Last 30 days
    },
    limit: 100,
    status: 'won'
  });
  
  // Analyze successful evidence submissions
  const successfulEvidenceTypes = {};
  const successfulEvidenceByReason = {};
  
  for (const dispute of recentDisputes.data) {
    // Get the full dispute with evidence
    const fullDispute = await stripe.disputes.retrieve(dispute.id);
    
    // Record which evidence fields were submitted
    const submittedEvidence = Object.keys(fullDispute.evidence).filter(
      key => fullDispute.evidence[key] !== null
    );
    
    // Count frequency of each evidence type
    submittedEvidence.forEach(evidenceType => {
      successfulEvidenceTypes[evidenceType] = (successfulEvidenceTypes[evidenceType] || 0) + 1;
      
      // Organize by dispute reason
      if (!successfulEvidenceByReason[dispute.reason]) {
        successfulEvidenceByReason[dispute.reason] = {};
      }
      
      successfulEvidenceByReason\[dispute.reason]\[evidenceType] = 
        (successfulEvidenceByReason\[dispute.reason]\[evidenceType] || 0) + 1;
    });
  }
  
  // Update evidence templates based on findings
  for (const reason in successfulEvidenceByReason) {
    const mostEffectiveEvidence = Object.entries(successfulEvidenceByReason[reason])
      .sort((a, b) => b[1] - a[1])
      .slice(0, 5)
      .map(entry => entry[0]);
    
    await database.updateEvidenceTemplate(reason, mostEffectiveEvidence);
  }
  
  // Report on findings
  return {
    successful_evidence_types: successfulEvidenceTypes,
    evidence_by_reason: successfulEvidenceByReason
  };
}

 

Conclusion

 

Handling chargebacks effectively is crucial for maintaining your business's financial health. By implementing a systematic approach to chargeback prevention, evidence collection, and dispute management, you can significantly improve your chances of winning disputes and reduce their frequency over time.

Remember that each dispute is an opportunity to learn about potential issues in your business processes. Regular analysis and continuous improvement of your chargeback handling system will yield long-term benefits for your business.

Want to explore opportunities to work with us?

Connect with our team to unlock the full potential of no-code solutions with a no-commitment consultation!

Book a Free Consultation

Client trust and success are our top priorities

When it comes to serving you, we sweat the little things. That’s why our work makes a big impact.

Rapid Dev was an exceptional project management organization and the best development collaborators I've had the pleasure of working with. They do complex work on extremely fast timelines and effectively manage the testing and pre-launch process to deliver the best possible product. I'm extremely impressed with their execution ability.

CPO, Praction - Arkady Sokolov

May 2, 2023

Working with Matt was comparable to having another co-founder on the team, but without the commitment or cost. He has a strategic mindset and willing to change the scope of the project in real time based on the needs of the client. A true strategic thought partner!

Co-Founder, Arc - Donald Muir

Dec 27, 2022

Rapid Dev are 10/10, excellent communicators - the best I've ever encountered in the tech dev space. They always go the extra mile, they genuinely care, they respond quickly, they're flexible, adaptable and their enthusiasm is amazing.

Co-CEO, Grantify - Mat Westergreen-Thorne

Oct 15, 2022

Rapid Dev is an excellent developer for no-code and low-code solutions.
We’ve had great success since launching the platform in November 2023. In a few months, we’ve gained over 1,000 new active users. We’ve also secured several dozen bookings on the platform and seen about 70% new user month-over-month growth since the launch.

Co-Founder, Church Real Estate Marketplace - Emmanuel Brown

May 1, 2024 

Matt’s dedication to executing our vision and his commitment to the project deadline were impressive. 
This was such a specific project, and Matt really delivered. We worked with a really fast turnaround, and he always delivered. The site was a perfect prop for us!

Production Manager, Media Production Company - Samantha Fekete

Sep 23, 2022