A Paystack transfer fails when the money cannot be delivered to the recipient bank account. The most common reasons are insufficient Paystack balance, invalid or closed recipient account, bank system downtime, and wrong currency. Check the transfer status via the API or dashboard, read the failure reason from the webhook payload, fix the root cause, and retry with the same transfer code or create a new transfer. Never auto-retry without understanding why the transfer failed.
When you call POST /transfer, Paystack does not immediately send money to the recipient. The process is asynchronous:
transfer.success or transfer.failed webhookFailure can happen at step 2 (insufficient balance), step 3 (bank downtime), or step 4 (invalid account). The API response to your initial POST /transfer call will return success if step 1 passes. That success means "transfer accepted for processing", not "money delivered".
// Initial transfer response: this does NOT mean the transfer succeeded
{
"status": true,
"message": "Transfer requires OTP to continue",
"data": {
"reference": "tf_abc123",
"status": "otp",
"transfer_code": "TRF_xyz789",
"amount": 5000000,
"currency": "NGN"
}
}
You must listen for the webhook to know the final outcome.
1. Insufficient Paystack balance
Your Paystack balance does not have enough funds to cover the transfer amount plus the transfer fee. This is the most common reason and also the easiest to prevent. Check your balance before initiating transfers.
// Check balance before transfer
async function checkBalance(): Promise {
const res = await fetch('https://api.paystack.co/balance', {
headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` },
});
const data = await res.json();
// Returns balance in kobo
return data.data[0].balance;
}
async function safeTransfer(amount: number, recipientCode: string) {
const balance = await checkBalance();
const transferFee = 1000; // 10 NGN flat fee in kobo (varies by amount)
if (balance < amount + transferFee) {
throw new Error(
`Insufficient balance. Need ${amount + transferFee} kobo, have ${balance} kobo`
);
}
// Proceed with transfer
}
2. Invalid or closed bank account
The recipient's bank account number does not exist, is closed, or does not match the bank code you provided. The bank rejects the credit and the money returns to your Paystack balance (within 24 to 72 hours).
3. Bank system downtime
The recipient's bank is having technical issues and cannot process incoming credits. This is transient. The transfer may succeed if retried later, or Paystack may retry automatically.
4. Wrong currency
You tried to send NGN to a GHS recipient or vice versa. The currency in your transfer request must match the recipient's currency.
5. Transfer limit exceeded
Your Paystack account has a daily or per-transaction transfer limit based on your tier. Contact Paystack support to increase limits if you need higher payouts.
6. Recipient account restricted
The recipient's bank account has restrictions (frozen, under investigation, post-no-debit/credit). The bank rejects the incoming credit.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When a transfer fails, Paystack sends a transfer.failed event to your webhook URL. The payload contains the failure reason:
{
"event": "transfer.failed",
"data": {
"amount": 5000000,
"currency": "NGN",
"domain": "live",
"reference": "tf_abc123",
"source": "balance",
"reason": "Transfer failed. Please contact the recipient bank.",
"status": "failed",
"transfer_code": "TRF_xyz789",
"recipient": {
"name": "John Doe",
"account_number": "0123456789",
"bank_code": "058",
"bank_name": "GTBank"
},
"created_at": "2026-07-20T10:30:00.000Z",
"updated_at": "2026-07-20T10:32:15.000Z"
}
}
The reason field is what you need. Common values include:
"Insufficient funds" or "Insufficient balance""Could not process transfer" (usually bank downtime)"Transfer failed. Please contact the recipient bank.""Invalid account number""Account name mismatch"Log the full webhook payload for debugging. The reason text can vary, so match on substrings rather than exact strings when building automated recovery.
Build a handler that receives the webhook, categorises the failure, and takes the appropriate action for each category.
// lib/transfer-failure-handler.ts
interface TransferFailedEvent {
event: 'transfer.failed';
data: {
reference: string;
transfer_code: string;
amount: number;
reason: string;
recipient: {
name: string;
account_number: string;
bank_name: string;
};
};
}
type FailureCategory = 'insufficient_balance' | 'invalid_account' | 'bank_downtime' | 'other';
function categoriseFailure(reason: string): FailureCategory {
const lower = reason.toLowerCase();
if (lower.includes('insufficient') || lower.includes('balance')) {
return 'insufficient_balance';
}
if (lower.includes('invalid account') || lower.includes('account name mismatch')) {
return 'invalid_account';
}
if (lower.includes('could not process') || lower.includes('contact the recipient bank')) {
return 'bank_downtime';
}
return 'other';
}
async function handleTransferFailed(event: TransferFailedEvent) {
const { reference, transfer_code, amount, reason, recipient } = event.data;
const category = categoriseFailure(reason);
// Log the failure
await db.query(
`INSERT INTO transfer_failures (reference, transfer_code, amount, reason, category, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())`,
[reference, transfer_code, amount, reason, category]
);
switch (category) {
case 'insufficient_balance':
// Alert the team to top up the Paystack balance
await sendSlackAlert({
channel: '#payments',
message: `Transfer ${reference} failed: insufficient balance. Amount: ${amount / 100} NGN. Top up your Paystack balance.`,
});
// Queue for retry after balance is topped up
await queueForRetry(reference, { delayMinutes: 60 });
break;
case 'invalid_account':
// Do NOT retry. The account is wrong.
await sendSlackAlert({
channel: '#payments',
message: `Transfer ${reference} failed: invalid account ${recipient.account_number} at ${recipient.bank_name}. Contact recipient ${recipient.name} for correct details.`,
});
// Notify the recipient to update their bank details
await notifyRecipientInvalidAccount(reference);
break;
case 'bank_downtime':
// Retry after a delay. Bank issues are usually resolved within hours.
await queueForRetry(reference, { delayMinutes: 30 });
break;
case 'other':
// Unknown reason. Alert the team for manual investigation.
await sendSlackAlert({
channel: '#payments',
message: `Transfer ${reference} failed with unknown reason: "${reason}". Manual investigation needed.`,
});
break;
}
}
async function queueForRetry(reference: string, options: { delayMinutes: number }) {
await db.query(
`UPDATE transfer_queue
SET status = 'retry_scheduled', retry_at = NOW() + interval '${options.delayMinutes} minutes'
WHERE reference = $1`,
[reference]
);
}
The key principle: categorise first, then act. Never apply the same recovery action to all failures.
Not all failed transfers should be retried. Here is a decision framework:
| Failure reason | Should retry? | When to retry | Max retries |
|---|---|---|---|
| Insufficient balance | Yes, after top-up | After you add funds | 1 |
| Invalid account | No | Never | 0 |
| Bank downtime | Yes | 30 min to 2 hours later | 3 |
| Account restricted | No | Never | 0 |
| Unknown/other | Maybe | After investigation | 1 |
For retries, use the original transfer details but generate a new reference. You can also use the transfer_code from the failed transfer to check its current status before retrying:
// Retry a failed transfer with safety checks
async function retryTransfer(originalReference: string) {
// 1. Get the original transfer details
const original = await db.query(
'SELECT * FROM transfers WHERE reference = $1',
[originalReference]
);
if (!original.rows.length) {
throw new Error('Original transfer not found');
}
const transfer = original.rows[0];
// 2. Check how many times we have already retried
const retryCount = await db.query(
'SELECT COUNT(*) FROM transfers WHERE original_reference = $1',
[originalReference]
);
if (parseInt(retryCount.rows[0].count) >= 3) {
throw new Error('Max retries exceeded for transfer ' + originalReference);
}
// 3. Check current Paystack balance
const balance = await checkBalance();
if (balance < transfer.amount + 1000) {
throw new Error('Still insufficient balance');
}
// 4. Create a new transfer with a new reference
const newReference = `retry_${originalReference}_${Date.now()}`;
const res = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount: transfer.amount,
recipient: transfer.recipient_code,
reason: transfer.reason,
reference: newReference,
}),
});
const data = await res.json();
// 5. Record the retry
await db.query(
`INSERT INTO transfers (reference, original_reference, recipient_code, amount, status, created_at)
VALUES ($1, $2, $3, $4, 'pending', NOW())`,
[newReference, originalReference, transfer.recipient_code, transfer.amount]
);
return data;
}
The best way to handle transfer failures is to prevent them. Validate the recipient's bank account before creating the recipient and initiating the transfer.
// Validate a bank account before creating a recipient
async function validateBankAccount(
accountNumber: string,
bankCode: string
): Promise<{ valid: boolean; accountName: string }> {
const res = await fetch(
`https://api.paystack.co/bank/resolve?account_number=${accountNumber}&bank_code=${bankCode}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await res.json();
if (data.status && data.data) {
return {
valid: true,
accountName: data.data.account_name,
};
}
return { valid: false, accountName: '' };
}
// Full transfer flow with validation
async function initiateTransfer(
accountNumber: string,
bankCode: string,
amount: number,
reason: string
) {
// Step 1: Validate the account
const validation = await validateBankAccount(accountNumber, bankCode);
if (!validation.valid) {
throw new Error(
'Bank account ' + accountNumber + ' at bank ' + bankCode + ' could not be resolved. ' +
'Ask the recipient to verify their account details.'
);
}
// Step 2: Check balance
const balance = await checkBalance();
if (balance < amount + 1000) {
throw new Error('Insufficient Paystack balance for this transfer');
}
// Step 3: Create recipient
const recipientRes = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'nuban',
name: validation.accountName,
account_number: accountNumber,
bank_code: bankCode,
currency: 'NGN',
}),
});
const recipientData = await recipientRes.json();
if (!recipientData.status) {
throw new Error('Failed to create recipient: ' + recipientData.message);
}
// Step 4: Initiate transfer
const transferRes = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount,
recipient: recipientData.data.recipient_code,
reason,
}),
});
return transferRes.json();
}
The /bank/resolve endpoint catches invalid account numbers before you attempt a transfer. It also returns the account holder's name, which you can show to the user for confirmation ("Is this the correct account? John Doe at GTBank"). This prevents a huge category of transfer failures.
Set up alerts so you know about transfer failures before your recipients complain. At minimum, monitor:
// Simple failure rate monitor
async function checkFailureRate() {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
const stats = await db.query(
`SELECT
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) as total
FROM transfers
WHERE created_at > $1`,
[oneHourAgo]
);
const { failed, total } = stats.rows[0];
const rate = total > 0 ? (failed / total) * 100 : 0;
if (rate > 5 && total > 10) {
await sendSlackAlert({
channel: '#payments-alerts',
message: `Transfer failure rate is ${rate.toFixed(1)}% (${failed}/${total}) in the last hour. Investigate immediately.`,
});
}
}
Run this check every 15 minutes. The total > 10 guard prevents false alarms from small sample sizes (2 out of 3 is 67% but not meaningful).
Test your transfer failure handling before relying on it in production:
transfer.failed webhook to your endpoint with different failure reasons. Confirm each reason triggers the correct recovery action.In production, review your transfer failure logs weekly. Look for patterns: same bank failing repeatedly (contact Paystack support), same recipient failing (invalid account that was not caught by validation), or failures clustered at certain times (bank maintenance windows).
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