Managing Transfer Failures and Reversals
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.
Why Transfers Fail
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.
Handling the transfer.failed Webhook
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.
Classifying Failures: Transient vs Permanent
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.
Retry Strategies That Work
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.
Understanding Reversals
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:
- You already told the recipient (vendor, driver, employee) that they were paid
- You may have updated your internal records to show the payout as complete
- The recipient may have already spent or withdrawn the money from their account
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.',
});
}
Reversal Recovery Process
When a reversal happens, follow this process:
- Update your records immediately. Change the payout status from completed to reversed. Do not wait for manual confirmation.
- Alert your operations team. Reversals need human attention. Send a notification with the payout details, recipient information, and the reversal timestamp.
- Communicate with the recipient. If you sent them a "you've been paid" notification, follow up honestly. Tell them the bank reversed the transfer and you are re-initiating it.
- Investigate the cause. Reversals usually happen because of issues on the receiving bank's side: the account cannot receive that amount, there is a compliance hold, or the bank's system rejected the credit after initially accepting it. Understanding the cause tells you whether a retry will work or if the recipient needs to fix something on their end.
- Re-initiate with a new reference. If the issue is resolved, create a new payout with a fresh reference. Do not reuse the original reference because Paystack will treat it as a duplicate of the (now reversed) original.
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.
Building an Operations Playbook
Create a document your team can follow when transfer issues arise. Cover each failure type with specific actions.
Insufficient balance
- Action: Fund the Paystack balance and re-run the payout batch
- Owner: Finance team
- SLA: Fund within 4 hours of notification
Invalid account (permanent)
- Action: Contact the recipient, request updated bank details, create a new recipient
- Owner: Operations team
- SLA: Contact within 24 hours
Bank downtime (transient)
- Action: Automated retry with exponential backoff (15, 30, 60 min)
- Owner: System handles automatically
- Escalation: If still failing after 3 retries, ops team investigates
Reversal
- Action: Alert ops team immediately. Investigate cause. Communicate with recipient. Re-initiate if appropriate.
- Owner: Senior operations
- SLA: Acknowledge within 1 hour, resolve within 24 hours
Unclassified failure
- Action: Manual review. Check Paystack dashboard for details. Contact Paystack support if needed.
- Owner: Engineering or operations
- SLA: Investigate within 4 hours
For the broader automated payout system that ties all these pieces together, see building an automated payout system on Paystack.
Key Takeaways
- ✓Transfer failures are common and predictable. The most frequent causes are insufficient balance, invalid accounts, bank downtime, and recipient account restrictions.
- ✓When a transfer fails, Paystack returns the amount to your available balance automatically. You do not need to request a refund.
- ✓Classify failures as transient (bank downtime, network timeout) or permanent (invalid account, closed account). Only retry transient failures.
- ✓Reversals are different from failures. A reversal means the transfer initially succeeded but was later pulled back. The money returns to your balance, but you may have already told the recipient they were paid.
- ✓Cap retries at 3 attempts with exponential backoff. Aggressive retrying on persistent failures burns rate limits and creates noise.
- ✓Build an operations playbook: what to do for each failure type, who to notify, when to escalate to Paystack support.
Frequently Asked Questions
- How quickly is my balance refunded after a failed transfer?
- Paystack refunds your balance automatically and immediately when a transfer fails. By the time you receive the transfer.failed webhook, the money should already be back in your available balance. You do not need to request or trigger the refund.
- Can I prevent reversals from happening?
- You cannot prevent reversals because they are initiated by the receiving bank, not by Paystack or your system. You can reduce the likelihood by ensuring recipient accounts are active, well-maintained, and have no restrictions. But reversals due to bank-side issues are outside your control.
- Should I notify the recipient when a transfer fails?
- It depends on your product. If the recipient is expecting the payout (vendor settlement, salary), notifying them about a delay is good practice. Keep the message simple: the payout is being retried and they will receive it shortly. If the failure is permanent (invalid account), you need to ask them for updated details.
- How do I handle a transfer stuck in pending for hours?
- First, fetch the transfer status from the Paystack API using the transfer code or reference. If Paystack shows it as pending, the banking network is still processing. If it shows as failed or success but you did not receive the webhook, update your records from the API response. If it has been pending for more than 24 hours, contact Paystack support with the transfer code.
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