Paystack transfers fail for predictable reasons: insufficient balance, invalid accounts, bank downtime, or recipient account restrictions. Failed transfers refund your balance automatically. Reversals happen when a successful transfer is later pulled back by the receiving bank. Handle both through webhook events (transfer.failed and transfer.reversed), classify failures as transient or permanent, and retry only transient ones.
Paystack transfers fail for a limited set of reasons, and each one requires a different response. Understanding the categories saves you from building a one-size-fits-all retry that makes things worse.
Insufficient balance
Your available Paystack balance does not cover the transfer amount plus fees. This fails immediately at the API level before the transfer enters the banking network. Prevention is straightforward: always check your balance before initiating transfers. See transfer balance checks before initiating payouts.
Invalid or closed bank account
The recipient's account does not exist, has been closed, or the account number was wrong. Even though Paystack validates accounts when you create the recipient, accounts can close between recipient creation and transfer initiation. This is a permanent failure. Do not retry. Instead, contact the recipient and ask them to provide updated bank details.
Bank downtime or network issues
The destination bank's systems are temporarily unavailable. This is surprisingly common across African banking infrastructure, especially during maintenance windows (typically late night or early morning) and month-end processing periods. This is a transient failure. Retry after a delay.
Recipient account restrictions
The recipient's account has a hold, is dormant, is frozen, or has hit daily inflow limits. This is generally a permanent failure from your perspective. The recipient needs to resolve the issue with their bank.
Transfer limit exceeded
Either the recipient's account has reached its daily inflow limit (common with lower-tier KYC accounts) or your own Paystack transfer limits have been reached. Check your dashboard for your current limits.
When a transfer fails, Paystack sends a transfer.failed webhook event. The payload includes the failure reason, which is your key for deciding what to do next.
app.post('/webhooks/paystack', async (req, res) => {
res.sendStatus(200);
var event = req.body;
if (event.event === 'transfer.failed') {
var reference = event.data.reference;
var reason = event.data.reason || 'Unknown reason';
var amount = event.data.amount;
var recipientCode = event.data.recipient.recipient_code;
// Find the payout record
var payout = await db.payouts.findByReference(reference);
if (!payout) {
console.error('Unknown payout reference: ' + reference);
return;
}
// Update the record
await db.payouts.update(payout.id, {
status: 'failed',
failureReason: reason,
failedAt: new Date(),
});
// Classify and respond
var classification = classifyFailure(reason);
if (classification === 'transient') {
await scheduleRetry(payout);
} else if (classification === 'permanent') {
await flagForManualReview(payout, reason);
} else {
await flagForManualReview(payout, 'Unclassified: ' + reason);
}
}
});
Always update your payout record before attempting any retry or escalation logic. If your retry logic throws an error, the payout record should still reflect the failure. Do not lose track of a failed transfer because your error handling crashed.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
function classifyFailure(reason) {
var lowerReason = (reason || '').toLowerCase();
// Transient: these usually resolve on retry
var transientKeywords = [
'timeout',
'temporarily unavailable',
'service unavailable',
'try again',
'network error',
'system error',
'processing error',
'bank session',
'connection',
];
for (var i = 0; i < transientKeywords.length; i++) {
if (lowerReason.indexOf(transientKeywords[i]) !== -1) {
return 'transient';
}
}
// Permanent: these will fail every time
var permanentKeywords = [
'invalid account',
'account closed',
'account does not exist',
'account frozen',
'account restricted',
'account dormant',
'wrong account',
'insufficient',
'limit exceeded',
'blocked',
];
for (var j = 0; j < permanentKeywords.length; j++) {
if (lowerReason.indexOf(permanentKeywords[j]) !== -1) {
return 'permanent';
}
}
// Unknown: treat as needs review
return 'unknown';
}
This keyword-based classification covers most cases. Log every failure reason you encounter and periodically review your logs to add new keywords. Paystack's failure messages can vary, and banks sometimes return unusual error strings that do not match your patterns. When in doubt, classify as unknown and send to manual review. It is better to have a human look at an ambiguous failure than to retry something that will never succeed.
async function scheduleRetry(payout) {
// Cap at 3 retries
if (payout.retryCount >= 3) {
await db.payouts.update(payout.id, {
status: 'max_retries_exceeded',
requiresManualReview: true,
});
await alertOps(
'Payout ' + payout.reference + ' failed after 3 retries. Last reason: '
+ payout.failureReason
);
return;
}
// Exponential backoff: 15 min, 30 min, 60 min
var delayMinutes = Math.pow(2, payout.retryCount) * 15;
var retryAt = new Date(Date.now() + delayMinutes * 60 * 1000);
await db.payouts.update(payout.id, {
status: 'retry_scheduled',
retryCount: payout.retryCount + 1,
retryAt: retryAt,
});
console.log(
'Retry scheduled for ' + payout.reference
+ ' at ' + retryAt.toISOString()
);
}
// Cron job or scheduled task that processes retries
async function processScheduledRetries() {
var dueRetries = await db.payouts.findDueRetries(new Date());
for (var i = 0; i < dueRetries.length; i++) {
var payout = dueRetries[i];
// Use a new reference for the retry to avoid Paystack's duplicate detection
var retryReference = payout.originalReference + '_r' + payout.retryCount;
try {
var result = await initiateTransfer(
payout.recipientCode,
payout.amountMinorUnit,
payout.reason,
retryReference
);
await db.payouts.update(payout.id, {
status: 'processing',
currentReference: retryReference,
transferCode: result.transferCode,
});
} catch (err) {
await db.payouts.update(payout.id, {
status: 'retry_failed',
note: 'Retry initiation error: ' + err.message,
});
}
}
}
The exponential backoff (15 minutes, 30 minutes, 60 minutes) gives the banking network time to recover from transient issues. Do not retry immediately. If a bank is down at 2:00 AM, it is probably still down at 2:01 AM. But it is likely back by 2:15 AM.
Notice the retry reference pattern: the original reference plus a retry counter. This prevents Paystack from treating the retry as a duplicate of the original (which failed), while maintaining a clear audit trail linking retries back to the original payout.
A reversal is different from a failure. Failures happen before the money reaches the recipient. Reversals happen after. The transfer was marked as successful, the recipient may have even seen the money in their account, and then the transaction is reversed by the receiving bank.
Reversals are rare, but they are the most operationally challenging event in a payout system because:
When a reversal happens, Paystack sends a transfer.reversed webhook event and returns the money to your Paystack balance.
if (event.event === 'transfer.reversed') {
var reference = event.data.reference;
var payout = await db.payouts.findByReference(reference);
if (!payout) {
console.error('Reversal for unknown reference: ' + reference);
return;
}
// Update the payout record
await db.payouts.update(payout.id, {
status: 'reversed',
reversedAt: new Date(),
note: 'Transfer reversed by receiving bank',
});
// Alert operations immediately
await alertOps(
'REVERSAL ALERT: Payout ' + reference
+ ' for ' + payout.amountDisplay
+ ' to ' + payout.recipientName
+ ' has been reversed.'
);
// Queue for re-processing
await db.payouts.update(payout.id, {
requiresManualReview: true,
reviewNote: 'Reversal. Verify with recipient and re-initiate if appropriate.',
});
}
When a reversal happens, follow this process:
Reversals that repeat on re-initiation indicate a persistent issue with the recipient's account. After two reversals to the same account, ask the recipient to provide alternative bank details or contact their bank.
Create a document your team can follow when transfer issues arise. Cover each failure type with specific actions.
Insufficient balance
Invalid account (permanent)
Bank downtime (transient)
Reversal
Unclassified failure
For the broader automated payout system that ties all these pieces together, see building an automated payout system on 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