Paystack Transfer Failed: Reasons and Recovery
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.
How Paystack Transfers Fail
When you call POST /transfer, Paystack does not immediately send money to the recipient. The process is asynchronous:
- Paystack receives your transfer request and validates the parameters
- Paystack debits your Paystack balance (not your bank account)
- Paystack sends the money to the recipient's bank via the banking network
- The bank either accepts or rejects the credit
- Paystack sends you a
transfer.successortransfer.failedwebhook
Failure 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.
Every Transfer Failure Reason
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.
The transfer.failed Webhook Payload
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.
Code: Transfer Failure Handler
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.
Retry Strategy for Failed Transfers
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;
}
Prevent Failures: Validate Before You Transfer
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.
Monitoring Transfer Failures
Set up alerts so you know about transfer failures before your recipients complain. At minimum, monitor:
- Failure rate. If more than 5% of transfers fail in a one-hour window, something systemic is wrong (low balance, bank outage).
- Insufficient balance events. These are fully preventable. Alert immediately so you can top up.
- Retry exhaustion. When a transfer has been retried the maximum number of times and still fails, someone needs to investigate manually.
// 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).
Verification: Confirm Your Recovery Works
Test your transfer failure handling before relying on it in production:
- Test insufficient balance. In test mode, check your test balance and attempt a transfer larger than the balance. Confirm your system catches the error and alerts the team.
- Test invalid account. Create a recipient with an obviously wrong account number and attempt a transfer. Confirm the webhook arrives with an appropriate failure reason and your system does NOT retry.
- Test the retry flow. Manually mark a transfer as failed with reason "bank downtime" in your database. Run your retry job. Confirm it creates a new transfer with a new reference linked to the original.
- Test the webhook handler. Send a mock
transfer.failedwebhook to your endpoint with different failure reasons. Confirm each reason triggers the correct recovery action. - Test balance checking. Call your balance check function and confirm it returns the correct amount in kobo. Verify that your transfer initiation function refuses to proceed when the balance is too low.
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).
Key Takeaways
- ✓Paystack transfers fail for five main reasons: insufficient Paystack balance, invalid or closed recipient bank account, bank system downtime, wrong currency for the recipient, and transfer limit exceeded. Each has a different fix.
- ✓The transfer.failed webhook event contains a reason field that tells you exactly why the transfer failed. Always log and act on this reason rather than guessing.
- ✓Insufficient balance is the most common reason for failed transfers. Check your Paystack balance before initiating bulk transfers. Paystack debits the amount from your balance at the point of transfer, not at settlement.
- ✓Invalid bank account transfers fail after Paystack sends the money to the bank and the bank rejects it. The money returns to your Paystack balance, but this can take 24 to 72 hours.
- ✓Do not auto-retry failed transfers blindly. An invalid account will fail again. Bank downtime will resolve on its own. Only retry after confirming the failure reason is transient.
- ✓For bulk payouts, implement a queue that processes transfers one at a time, handles failures individually, and sends alerts when the failure rate exceeds a threshold.
- ✓Always create recipients before initiating transfers. Validate the account number and bank code using the /bank/resolve endpoint first. This catches invalid accounts before you attempt a transfer.
Frequently Asked Questions
- What happens to the money when a Paystack transfer fails?
- If Paystack debited your balance before the transfer failed (for example, the bank rejected the credit), the money is returned to your Paystack balance. This reversal can take 24 to 72 hours. If the transfer failed because of insufficient balance, no money was debited in the first place.
- Can I retry a failed Paystack transfer automatically?
- You can, but only for transient failures like bank downtime. Never auto-retry for invalid accounts or insufficient balance. For bank downtime, wait at least 30 minutes before retrying and cap retries at 3 attempts. Always check the failure reason before deciding whether to retry.
- How do I check my Paystack balance before a transfer?
- Call GET /balance with your secret key. The response includes your available balance in kobo (or pesewas for GHS). Compare this against the transfer amount plus the transfer fee before initiating the transfer. The fee varies by amount and by your Paystack plan.
- Why did my transfer fail with "Could not process transfer" even though the account is valid?
- This usually means the recipient bank is experiencing downtime or technical issues. It is not a problem with your integration or the recipient account. Wait 30 minutes to a few hours and retry. If it persists for more than 24 hours, contact Paystack support and ask them to check the status of the recipient bank on the switching network.
- How do I validate a bank account before making a transfer?
- Call GET /bank/resolve with the account_number and bank_code as query parameters. If the account is valid, Paystack returns the account holder name. If invalid, you get an error. Always validate before creating a transfer recipient. This catches wrong account numbers before you lose money to failed transfers.
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