Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Disputes and Chargebacks: The Developer Playbook

Paystack notifies you of chargebacks via the charge.dispute.create webhook event. You respond by fetching the dispute with GET /dispute/{id}, uploading evidence through POST /dispute/{id}/upload_url and POST /dispute/{id}/resolve, and tracking the resolution via charge.dispute.remind and charge.dispute.resolve webhooks. You typically have 24 to 72 hours to respond before the dispute auto-resolves in the customer favor.

What Is a Chargeback and Why Developers Should Care

A chargeback happens when a customer goes to their bank and says, "I did not make this transaction" or "I did not receive what I paid for." The bank reverses the charge and debits the merchant. Paystack sits in the middle. When a chargeback hits, Paystack debits your balance for the disputed amount and notifies you.

As a developer, chargebacks affect you in three ways:

  • Money leaves your balance immediately. Paystack holds the disputed amount while the case is open. If you lose, the money goes back to the customer. If you win, it returns to your balance.
  • You have a deadline to respond. Typically 24 to 72 hours after notification. Miss it, and you lose by default.
  • Your dispute rate matters. Card networks (Visa, Mastercard) track merchant dispute rates. Too many chargebacks and your payment processing can be restricted or shut down entirely.

You cannot prevent all chargebacks. Some are legitimate (the customer truly did not receive the product). Some are fraud (a stolen card was used on your platform). Some are "friendly fraud" (the customer received the product but disputes anyway). Your job is to build systems that minimize disputes, detect them early, and respond with strong evidence when they arrive.

The Dispute Lifecycle on Paystack

A dispute on Paystack follows this path:

  1. Dispute created. A customer files a chargeback with their bank. The bank notifies Paystack. Paystack creates a dispute record and sends you a charge.dispute.create webhook.
  2. Evidence window. You have a limited time (check the due_at field in the dispute object) to upload evidence and submit your response. This is your chance to prove the transaction was legitimate.
  3. Reminder. If you have not responded and the deadline is approaching, Paystack sends a charge.dispute.remind webhook. Treat this as a last-chance alarm.
  4. Resolution. Paystack or the issuing bank resolves the dispute. You receive a charge.dispute.resolve webhook with the outcome: merchant-won or customer-won.

The key timestamps to track in your system are: when the dispute was created, when your response was submitted, and the due_at deadline. If you automate evidence submission, you should be responding within minutes of the webhook, not hours.

Every dispute has a status field that moves through these values: awaiting-merchant-feedback, merchant-feedback-pending, resolved. Your system should track this status and act accordingly at each stage.

Fetching Disputes from the API

When you receive a dispute webhook, the payload contains the dispute data. But you can also fetch disputes proactively using the Dispute API.

// disputeService.js
var https = require('https');

function fetchDispute(disputeId) {
  var options = {
    hostname: 'api.paystack.co',
    port: 443,
    path: '/dispute/' + disputeId,
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    },
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = '';
      res.on('data', function(chunk) { body += chunk; });
      res.on('end', function() {
        var parsed = JSON.parse(body);
        if (parsed.status) {
          resolve(parsed.data);
        } else {
          reject(new Error(parsed.message));
        }
      });
    });

    req.on('error', reject);
    req.end();
  });
}

// List disputes with filters
function listDisputes(params) {
  var query = '?perPage=' + (params.perPage || 50)
    + '&page=' + (params.page || 1);
  if (params.status) {
    query += '&status=' + params.status;
  }
  if (params.from) {
    query += '&from=' + params.from;
  }
  if (params.to) {
    query += '&to=' + params.to;
  }

  var options = {
    hostname: 'api.paystack.co',
    port: 443,
    path: '/dispute' + query,
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    },
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = '';
      res.on('data', function(chunk) { body += chunk; });
      res.on('end', function() {
        var parsed = JSON.parse(body);
        if (parsed.status) {
          resolve(parsed.data);
        } else {
          reject(new Error(parsed.message));
        }
      });
    });

    req.on('error', reject);
    req.end();
  });
}

The dispute object contains the transaction reference, customer details, the disputed amount, the reason category, the due_at deadline, and the current status. Use the transaction reference to look up the order in your database and gather evidence.

Submitting Evidence

Winning a dispute requires evidence. Paystack lets you upload documents and submit a resolution through the API. The process has two steps: get an upload URL, then upload your file.

// evidenceUpload.js
var https = require('https');
var fs = require('fs');
var path = require('path');

function getUploadUrl(disputeId) {
  var options = {
    hostname: 'api.paystack.co',
    port: 443,
    path: '/dispute/' + disputeId + '/upload_url',
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    },
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = '';
      res.on('data', function(chunk) { body += chunk; });
      res.on('end', function() {
        var parsed = JSON.parse(body);
        if (parsed.status) {
          resolve(parsed.data);
        } else {
          reject(new Error(parsed.message));
        }
      });
    });

    req.on('error', reject);
    req.end();
  });
}

// Resolve the dispute with evidence
function resolveDispute(disputeId, resolution, evidence, uploadFilename) {
  var params = JSON.stringify({
    resolution: resolution,        // 'merchant-accepted' or 'declined'
    message: evidence.message,
    refund_amount: evidence.refundAmount,  // in kobo, for partial resolution
    upload_filename: uploadFilename,
  });

  var options = {
    hostname: 'api.paystack.co',
    port: 443,
    path: '/dispute/' + disputeId + '/resolve',
    method: 'PUT',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(params),
    },
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = '';
      res.on('data', function(chunk) { body += chunk; });
      res.on('end', function() {
        var parsed = JSON.parse(body);
        if (parsed.status) {
          resolve(parsed.data);
        } else {
          reject(new Error(parsed.message));
        }
      });
    });

    req.on('error', reject);
    req.write(params);
    req.end();
  });
}

Evidence that tends to win disputes:

  • Proof of delivery. Shipping tracking numbers, delivery confirmation, signed receipts. For digital products: download logs, access logs, IP addresses.
  • Customer communication. Emails, chat transcripts, or support tickets showing the customer acknowledged receipt or used the product.
  • Transaction details. Screenshots showing the customer's email, IP address, device fingerprint, and billing address matching the cardholder's details.
  • Terms of service. If your refund policy is clear and the customer agreed to it, include the relevant section.
  • Previous successful transactions. If the same customer has made multiple purchases before without disputes, it suggests they are familiar with your business.

You can decline a dispute (you believe the transaction was legitimate and want to fight it) or accept it (you agree with the customer). Accepting is equivalent to a refund but through the dispute channel.

Building an Automated Dispute Response System

The best dispute response is one that fires automatically. When a webhook arrives, your system should gather evidence from your database, compile it, and submit it without human intervention. Here is the architecture:

// automatedDisputeHandler.js
var crypto = require('crypto');

async function handleDisputeWebhook(event) {
  if (event.event === 'charge.dispute.create') {
    var dispute = event.data;
    var transactionRef = dispute.transaction.reference;

    console.log('New dispute on transaction: ' + transactionRef);

    // 1. Look up the order in your database
    var order = await db.query(
      'SELECT * FROM orders WHERE paystack_reference = $1',
      [transactionRef]
    );

    if (!order) {
      console.error('No order found for disputed transaction: ' + transactionRef);
      await alertSupportTeam({
        type: 'dispute_no_order',
        disputeId: dispute.id,
        transactionRef: transactionRef,
      });
      return;
    }

    // 2. Gather evidence automatically
    var evidence = await gatherEvidence(order);

    // 3. Record the dispute in your system
    await db.query(
      'INSERT INTO disputes (dispute_id, order_id, amount, status, due_at, created_at) VALUES ($1, $2, $3, $4, $5, NOW())',
      [dispute.id, order.id, dispute.amount, 'pending', dispute.due_at]
    );

    // 4. Auto-respond if we have strong evidence
    if (evidence.confidence === 'high') {
      await submitDisputeResponse(dispute.id, evidence);
      console.log('Auto-responded to dispute ' + dispute.id);
    } else {
      // Queue for manual review
      await alertSupportTeam({
        type: 'dispute_needs_review',
        disputeId: dispute.id,
        orderId: order.id,
        confidence: evidence.confidence,
      });
    }
  }

  if (event.event === 'charge.dispute.remind') {
    var dispute = event.data;
    console.log('Dispute reminder: ' + dispute.id + ' due at ' + dispute.due_at);

    // Check if we already responded
    var existing = await db.query(
      'SELECT status FROM disputes WHERE dispute_id = $1',
      [dispute.id]
    );

    if (existing && existing.status === 'pending') {
      // Escalate - we have not responded yet
      await alertSupportTeam({
        type: 'dispute_urgent',
        disputeId: dispute.id,
        dueAt: dispute.due_at,
        message: 'Dispute deadline approaching with no response submitted',
      });
    }
  }

  if (event.event === 'charge.dispute.resolve') {
    var dispute = event.data;
    var resolution = dispute.resolution;

    await db.query(
      'UPDATE disputes SET status = $1, resolved_at = NOW() WHERE dispute_id = $2',
      [resolution, dispute.id]
    );

    console.log('Dispute ' + dispute.id + ' resolved: ' + resolution);
  }
}

async function gatherEvidence(order) {
  var evidence = {
    message: '',
    confidence: 'low',
  };

  var parts = [];

  // Check delivery status
  if (order.delivery_status === 'delivered') {
    parts.push('Order was delivered on ' + order.delivered_at + '.');
    if (order.tracking_number) {
      parts.push('Tracking number: ' + order.tracking_number + '.');
    }
    evidence.confidence = 'high';
  }

  // Check for digital product access
  if (order.type === 'digital') {
    var accessLogs = await db.query(
      'SELECT accessed_at, ip_address FROM access_logs WHERE order_id = $1',
      [order.id]
    );
    if (accessLogs.length > 0) {
      parts.push(
        'Customer accessed the digital product ' + accessLogs.length + ' times.'
      );
      parts.push('Last access: ' + accessLogs[0].accessed_at + '.');
      evidence.confidence = 'high';
    }
  }

  // Check support ticket history
  var tickets = await db.query(
    'SELECT subject, created_at FROM support_tickets WHERE customer_email = $1 AND order_id = $2',
    [order.customer_email, order.id]
  );
  if (tickets.length > 0) {
    parts.push(
      'Customer opened ' + tickets.length + ' support tickets for this order.'
    );
  }

  evidence.message = parts.join(' ');
  return evidence;
}

The key insight is the confidence score. If your system can automatically verify delivery or product access, respond immediately. If the evidence is thin, route to a human. This hybrid approach keeps response times fast while avoiding auto-declining disputes you should actually accept.

Preventing Disputes Before They Happen

The cheapest dispute is the one that never happens. Here are developer-level strategies:

Clear billing descriptors. Make sure the name that appears on the customer's bank statement is recognizable. If your company name is "Acme Technologies Ltd" but your product is called "QuickShop," the customer sees "Acme Technologies" on their statement and thinks it is fraud. Configure your Paystack business name to match what customers expect.

Send transaction receipts immediately. After a successful payment, email the customer a receipt with the transaction reference, date, amount, and what they purchased. When they see the bank charge, they can cross-reference with the email instead of calling the bank.

Make refunds easy. If a customer wants a refund and cannot find how to request one, they call the bank instead. A visible "Request Refund" button is cheaper than a chargeback. See the refund guide for implementation details.

Verify customer identity. Use customer validation and BVN verification for high-value transactions. This reduces fraud and gives you stronger evidence if a dispute arises.

Log everything. IP addresses, device fingerprints, timestamps, and user agent strings at the time of payment. These logs are your defense in a dispute. If you can show that the same device and IP that made the purchase also logged into the account 50 times after, the chargeback claim of "I did not make this purchase" falls apart.

Monitor your dispute rate. Run a weekly query that calculates disputes as a percentage of total transactions. If it crosses 1%, investigate the source. Are certain products generating more disputes? Are certain customer segments problematic? Card networks start asking questions when your rate exceeds their thresholds.

Dispute Resolution Strategies

Not every dispute should be fought. Sometimes accepting the dispute is the smarter business move. Here is a decision framework:

  • Accept immediately if the customer never received the product or service and you can verify this. Fighting a legitimate complaint wastes time and damages trust.
  • Accept immediately if the disputed amount is very small and the cost of gathering and submitting evidence exceeds the amount at stake.
  • Decline and fight if you have clear proof of delivery, product access, or customer acknowledgment. Submit evidence through the Dispute API.
  • Offer a partial refund if the customer has a partial claim (received a damaged item, only used part of a service). The resolve endpoint accepts a refund_amount for partial resolution.

When you accept a dispute, the money stays with the customer. When you decline and win, the money returns to your balance. When you decline and lose, the money stays with the customer and your dispute record shows a loss. Too many losses without resolution attempts can flag your account.

For a complete view of how your payment system handles edge cases, read the complete guide to accepting payments with Paystack.

Key Takeaways

  • A chargeback happens when a customer contacts their bank to reverse a transaction. Paystack notifies you via the charge.dispute.create webhook event. You must respond within the deadline or the dispute auto-resolves in the customer favor.
  • The Dispute API lets you fetch dispute details, upload evidence documents, and submit your resolution. All of this can be automated so your system responds within minutes, not hours.
  • Evidence that wins disputes includes proof of delivery, customer communication logs, IP addresses, and signed terms of service. Collect this data at transaction time, not after the dispute arrives.
  • Paystack sends three key webhook events for disputes: charge.dispute.create (new dispute), charge.dispute.remind (deadline approaching), and charge.dispute.resolve (final outcome).
  • A high dispute rate can affect your merchant standing with Paystack and the card networks. Proactive refunds on legitimate complaints are cheaper than lost chargebacks.
  • Build your dispute response as a queue-based system. When the webhook arrives, enqueue the dispute, gather evidence from your database, and submit automatically. Manual intervention should be the fallback, not the primary path.

Frequently Asked Questions

How long do I have to respond to a Paystack dispute?
The response window is typically 24 to 72 hours, but it varies. Always check the due_at field in the dispute object. This is the exact deadline. Build your automated system to respond within minutes, not hours, so the deadline is never a concern.
What happens if I do not respond to a dispute?
If you do not respond before the due_at deadline, the dispute auto-resolves in the customer favor. The disputed amount is permanently deducted from your balance. Always respond, even if you accept the dispute, because it shows good faith to Paystack and the card networks.
Can I issue a refund on a transaction that has an open dispute?
It is not recommended. If you refund a transaction that also has a chargeback, you may end up returning the money twice: once through the refund and once through the chargeback resolution. If you want to accept the dispute, use the Dispute API resolve endpoint with resolution set to merchant-accepted.
What is the difference between a dispute and a refund?
A refund is initiated by you, the merchant, through the Refund API. A dispute (chargeback) is initiated by the customer through their bank. Refunds are under your control and can be full or partial. Disputes are adversarial: the bank is asking for the money back, and you must defend the transaction or accept the loss.
Does Paystack charge a fee for disputes?
Paystack may charge dispute-related fees depending on your merchant agreement and the card network rules. Check your Paystack dashboard or merchant agreement for the specific terms that apply to your account.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon