Bonaventure OgetoBy Bonaventure Ogeto|

Chargeback Accounting for Paystack Transactions

When a customer initiates a chargeback, Paystack sends a charge.dispute.create webhook. Record the dispute as a separate ledger entry with event_type "chargeback.created" and direction "debit". Track the dispute status through its lifecycle (created, awaiting merchant response, resolved). Submit evidence via the Paystack API or dashboard. If you lose the dispute, the chargeback amount is deducted from your settlements.

What Is a Chargeback and Why It Happens

A chargeback happens when a customer contacts their bank (not you, not Paystack) to dispute a charge on their card. The bank reverses the charge and pulls the money back. You lose the funds unless you can prove the charge was legitimate.

Common reasons customers initiate chargebacks:

  • Fraud: The card was stolen and used without the cardholder's knowledge.
  • Product not received: The customer paid but claims they never got the product or service.
  • Product not as described: The customer got something different from what they expected.
  • Duplicate charge: The customer was charged twice for the same transaction.
  • Friendly fraud: The customer received the product but disputes the charge anyway, hoping to get both the product and the refund.

Chargebacks are expensive. Beyond losing the transaction amount, they may carry additional fees from the card network and can damage your reputation with payment processors. A high chargeback rate can lead to higher processing fees or account termination.

For the broader financial tracking system, see the payments ledger design guide.

Handling the Dispute Webhook

Paystack sends webhook events for dispute lifecycle changes. The key events are:

  • charge.dispute.create: A new dispute has been raised.
  • charge.dispute.remind: A reminder that you need to respond.
  • charge.dispute.resolve: The dispute has been resolved (won or lost).
// Webhook handler for disputes
app.post('/webhooks/paystack', async function(req, res) {
  // Validate HMAC signature (not shown)
  var event = req.body;

  if (event.event === 'charge.dispute.create') {
    await handleDisputeCreated(event.data);
  } else if (event.event === 'charge.dispute.resolve') {
    await handleDisputeResolved(event.data);
  }

  return res.sendStatus(200);
});

async function handleDisputeCreated(disputeData) {
  var reference = disputeData.transaction_reference
    || (disputeData.transaction ? disputeData.transaction.reference : null);

  // Record in ledger
  await db.query(
    'INSERT INTO payment_ledger '
    + '(reference, event_type, direction, gross_amount, fee_amount, '
    + 'net_amount, currency, metadata) '
    + 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8) '
    + 'ON CONFLICT (reference, event_type) DO NOTHING',
    [
      reference,
      'chargeback.created',
      'debit',
      disputeData.amount,
      0,
      disputeData.amount,
      disputeData.currency,
      JSON.stringify(disputeData),
    ]
  );

  // Record in disputes tracking table
  await db.query(
    'INSERT INTO disputes '
    + '(paystack_dispute_id, reference, amount, currency, reason, '
    + 'status, due_at, created_at) '
    + 'VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) '
    + 'ON CONFLICT (paystack_dispute_id) DO NOTHING',
    [
      disputeData.id,
      reference,
      disputeData.amount,
      disputeData.currency,
      disputeData.message || disputeData.category,
      'awaiting_response',
      disputeData.due_at,
    ]
  );

  // Alert team immediately - disputes have deadlines
  await sendAlert('NEW CHARGEBACK - respond by ' + disputeData.due_at, {
    reference: reference,
    amount: disputeData.amount,
    currency: disputeData.currency,
    reason: disputeData.message,
    deadline: disputeData.due_at,
  });
}

Tracking Dispute Lifecycle

Disputes move through several stages. Track each stage so your team knows what needs attention:

-- Disputes tracking table
CREATE TABLE disputes (
  id                  BIGSERIAL PRIMARY KEY,
  paystack_dispute_id BIGINT UNIQUE NOT NULL,
  reference           VARCHAR(100) NOT NULL,
  amount              BIGINT NOT NULL,
  currency            VARCHAR(3) NOT NULL,
  reason              TEXT,
  status              VARCHAR(30) NOT NULL DEFAULT 'created',
  -- 'created', 'awaiting_response', 'evidence_submitted',
  -- 'won', 'lost', 'expired'
  evidence_submitted  BOOLEAN NOT NULL DEFAULT FALSE,
  evidence_notes      TEXT,
  due_at              TIMESTAMPTZ,
  resolved_at         TIMESTAMPTZ,
  outcome             VARCHAR(20),
  -- 'merchant_won', 'customer_won', 'expired'
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Open disputes that need attention
SELECT
  reference,
  amount,
  currency,
  reason,
  status,
  due_at,
  EXTRACT(EPOCH FROM (due_at - NOW())) / 3600 as hours_remaining
FROM disputes
WHERE status IN ('created', 'awaiting_response')
ORDER BY due_at ASC;

The hours_remaining column in the query shows how much time you have left to respond. Disputes have strict deadlines. If you miss the deadline, you automatically lose. Your team should check this query daily and prioritize disputes with the least time remaining.

Submitting Evidence

To win a dispute, you need to prove the charge was legitimate. The type of evidence depends on the dispute reason:

  • For "product not received": Delivery confirmation, tracking numbers, signed receipts, or for digital products, access logs showing the customer used the product.
  • For "not as described": Product descriptions shown at checkout, terms of service the customer agreed to, screenshots of the product page.
  • For "unauthorized transaction": IP address logs, device fingerprints, 3D Secure authentication records, customer communication confirming the purchase.
  • For "duplicate charge": Transaction logs showing only one charge, or explanation of why two charges were correct (two separate orders).
// Gather evidence for a dispute
async function gatherEvidence(reference) {
  // Pull all relevant data from your system
  var order = await db.query(
    'SELECT * FROM orders WHERE paystack_reference = $1',
    [reference]
  );

  var ledgerEntries = await db.query(
    'SELECT * FROM payment_ledger WHERE reference = $1 ORDER BY created_at',
    [reference]
  );

  var verification = await db.query(
    'SELECT metadata FROM payment_ledger '
    + 'WHERE reference = $1 AND event_type = $2',
    [reference, 'charge.success']
  );

  var evidence = {
    order: order.rows[0],
    transaction_timeline: ledgerEntries.rows,
    paystack_verification: verification.rows[0]
      ? JSON.parse(verification.rows[0].metadata) : null,
  };

  // For digital products, add access logs
  if (order.rows[0] && order.rows[0].product_type === 'course') {
    var accessLogs = await db.query(
      'SELECT accessed_at, ip_address, user_agent FROM access_logs '
      + 'WHERE user_id = $1 AND course_id = $2 ORDER BY accessed_at',
      [order.rows[0].user_id, order.rows[0].product_id]
    );
    evidence.access_logs = accessLogs.rows;
  }

  return evidence;
}

Submit evidence through the Paystack dashboard or API. Include as much detail as possible. Clear, organized evidence significantly improves your chances of winning the dispute.

Accounting for Dispute Resolution

When a dispute is resolved, update both your disputes table and your ledger:

// Handle dispute resolution
async function handleDisputeResolved(disputeData) {
  var reference = disputeData.transaction_reference
    || (disputeData.transaction ? disputeData.transaction.reference : null);

  var outcome = disputeData.status === 'resolved'
    ? (disputeData.resolution === 'merchant-accepted' ? 'merchant_won' : 'customer_won')
    : 'expired';

  // Update dispute record
  await db.query(
    'UPDATE disputes SET status = $1, outcome = $2, resolved_at = NOW() '
    + 'WHERE paystack_dispute_id = $3',
    [outcome, outcome, disputeData.id]
  );

  if (outcome === 'merchant_won') {
    // We won. The chargeback is reversed.
    // Add a reversal entry to cancel out the chargeback debit
    await db.query(
      'INSERT INTO payment_ledger '
      + '(reference, event_type, direction, gross_amount, fee_amount, '
      + 'net_amount, currency, metadata) '
      + 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8) '
      + 'ON CONFLICT (reference, event_type) DO NOTHING',
      [
        reference,
        'chargeback.reversed',
        'credit',
        disputeData.amount,
        0,
        disputeData.amount,
        disputeData.currency,
        JSON.stringify(disputeData),
      ]
    );

    console.log('Dispute won for ' + reference + '. Chargeback reversed.');
  } else {
    // We lost. The chargeback stands.
    // The debit entry from 'chargeback.created' remains.
    // Update order status
    await db.query(
      'UPDATE orders SET status = $1 WHERE paystack_reference = $2',
      ['chargebacked', reference]
    );

    // Revoke access if applicable
    await revokeAccess(reference);

    console.log('Dispute lost for ' + reference + '. Chargeback confirmed.');
  }
}

If you win the dispute, the chargeback is reversed and the money comes back. Add a "chargeback.reversed" credit entry to your ledger to cancel out the original "chargeback.created" debit. The net effect is zero. If you lose, the "chargeback.created" debit stands and the money is gone.

Monitoring Your Chargeback Rate

Card networks (Visa, Mastercard) monitor your chargeback rate. If it exceeds their threshold (typically around 1% of transactions), you face consequences: higher processing fees, mandatory fraud prevention programs, or losing the ability to accept cards entirely.

-- Monthly chargeback rate
SELECT
  DATE_TRUNC('month', d.created_at) as month,
  d.currency,
  COUNT(d.id) as chargeback_count,
  (SELECT COUNT(*) FROM payment_ledger pl
   WHERE pl.event_type = 'charge.success'
   AND DATE_TRUNC('month', pl.created_at) = DATE_TRUNC('month', d.created_at)
   AND pl.currency = d.currency) as total_transactions,
  ROUND(
    COUNT(d.id)::numeric * 100 /
    NULLIF((SELECT COUNT(*) FROM payment_ledger pl2
     WHERE pl2.event_type = 'charge.success'
     AND DATE_TRUNC('month', pl2.created_at) = DATE_TRUNC('month', d.created_at)
     AND pl2.currency = d.currency), 0), 3
  ) as chargeback_rate_pct
FROM disputes d
GROUP BY DATE_TRUNC('month', d.created_at), d.currency
ORDER BY month DESC;

Track this monthly. If you see the rate approaching 0.5%, take action immediately. Review your product descriptions, add more fraud detection, improve your refund process (customers who can easily get refunds are less likely to initiate chargebacks), and consider adding 3D Secure authentication for all transactions.

Preventing Chargebacks

Prevention is cheaper than disputes. Here are concrete steps:

  • Use clear descriptors. The charge description on the customer's bank statement should clearly identify your business. If it shows a cryptic string, customers may not recognize the charge and file a dispute.
  • Send confirmation emails immediately. When a payment succeeds, send a receipt with the product name, amount, and your contact information. Customers who can easily check their purchase history are less likely to dispute.
  • Make refunds easy. If a customer wants their money back, a refund is much better than a chargeback. Refunds do not affect your chargeback rate. Chargebacks do. Add a clear refund process and communicate it to customers.
  • Enable 3D Secure. 3D Secure adds an extra authentication step during card payments. If a 3DS-authenticated transaction is disputed as unauthorized, the liability typically shifts to the card issuer, not you.
  • Log everything. Store IP addresses, device information, customer communication, delivery confirmations. When a dispute comes in, your evidence is already collected.

Your payments ledger with its JSONB metadata column is part of this prevention strategy. The full Paystack response stored at charge time contains the IP address, card details, and authentication status that you will need during a dispute.

Calculating the Financial Impact of Chargebacks

-- Total financial impact of chargebacks
SELECT
  currency,
  COUNT(*) FILTER (WHERE outcome = 'customer_won' OR outcome = 'expired') as lost_disputes,
  COUNT(*) FILTER (WHERE outcome = 'merchant_won') as won_disputes,
  SUM(amount) FILTER (WHERE outcome = 'customer_won' OR outcome = 'expired') as total_lost,
  SUM(amount) FILTER (WHERE outcome = 'merchant_won') as total_recovered,
  SUM(amount) as total_disputed
FROM disputes
WHERE created_at >= '2026-01-01'
GROUP BY currency;

The "total_lost" figure represents the money you will never recover. The "total_recovered" is chargebacks you won. The difference between "total_disputed" and "total_recovered" is your actual financial loss from chargebacks. Add this to your monthly financial reports alongside refunds and fees for a complete picture of payment costs.

Key Takeaways

  • Chargebacks are initiated by the customer through their bank, not through Paystack. They can happen weeks or months after the original transaction.
  • Paystack notifies you of disputes via the charge.dispute.create webhook. Handle this webhook immediately since you have limited time to respond.
  • Record the chargeback as a new ledger entry. Never modify the original charge entry. The original charge and the chargeback are separate events.
  • Submit evidence (delivery proof, customer communication, transaction logs) through the Paystack dashboard or API within the deadline.
  • If you lose the dispute, the chargeback amount plus any fees are deducted from future settlements.
  • Track your chargeback rate. Card networks penalize merchants with high chargeback rates, which can lead to higher fees or account restrictions.

Frequently Asked Questions

How long after a transaction can a customer file a chargeback?
Card network chargeback windows typically extend 120 days from the transaction date, though some dispute types allow longer. This is why you must keep transaction logs and evidence for at least 6-12 months.
Can I prevent chargebacks on mobile money or bank transfer payments?
Chargebacks are primarily a card payment mechanism. Mobile money and bank transfer payments have different dispute processes that go through the customer bank or mobile money provider directly. The risk is lower but not zero.
What happens to the original charge in my ledger when a chargeback is filed?
The original charge entry stays unchanged. You add a new "chargeback.created" debit entry. If you win the dispute, you add a "chargeback.reversed" credit entry. The complete history is preserved.
Do chargebacks carry additional fees beyond the transaction amount?
Card networks and payment processors may charge chargeback fees on top of the disputed amount. Check your Paystack agreement for the specific fee structure. Record any additional fees as separate ledger entries.
Should I automatically revoke access when a chargeback is filed or when I lose?
This depends on your policy. Some businesses revoke access immediately when a chargeback is filed (to protect against ongoing fraud). Others wait until the dispute is resolved. The conservative approach is to revoke on filing and restore on winning.

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