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.
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:
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.
A dispute on Paystack follows this path:
charge.dispute.create webhook.charge.dispute.remind webhook. Treat this as a last-chance alarm.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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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:
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.
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.
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.
Not every dispute should be fought. Sometimes accepting the dispute is the smarter business move. Here is a decision framework:
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.
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