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.
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:
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.
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,
});
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
To win a dispute, you need to prove the charge was legitimate. The type of evidence depends on the dispute reason:
// 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.
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.
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.
Prevention is cheaper than disputes. Here are concrete steps:
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.
-- 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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course