Handling transfer.success, transfer.failed and transfer.reversed
Paystack transfers are asynchronous. When you initiate a transfer via the API, it returns a pending status. The final result arrives as a webhook event: transfer.success means the money reached the recipient, transfer.failed means the transfer did not go through and the money remains in your Paystack balance, and transfer.reversed means a previously successful transfer was reversed by the bank and the money returned to your balance. Handle all three to keep your payout records accurate.
Why Transfers Need Webhooks
When you call the Paystack Transfer API to send money to a bank account, the API response does not tell you if the transfer succeeded. It tells you the transfer was queued. The status in the response is "pending" (or sometimes "otp" if OTP confirmation is required).
The actual outcome depends on the recipient's bank. The bank might accept the transfer immediately, reject it (wrong account number, dormant account), or accept it and reverse it hours later. Paystack communicates the result through webhook events.
If you mark a payout as "completed" based on the API response alone, you will tell users their withdrawal succeeded when it might still fail. If you skip webhook handling entirely, you will never know when a transfer fails or gets reversed, and your ledger will be wrong.
This is different from charge.success, where the payment outcome is immediate. Transfers involve an external bank, and banks operate on their own timeline.
The Three Transfer States
After you initiate a transfer, it moves through one of three final states:
1. transfer.success - The money left your Paystack balance and arrived in the recipient's bank account. This is the happy path. Your payout is complete.
2. transfer.failed - The transfer was rejected. The money did not leave your Paystack balance (or it was returned immediately). Common failure reasons include:
- Invalid account number
- Account is dormant or closed
- Bank-side processing error
- Insufficient balance in your Paystack account
- Transfer amount exceeds the bank's limits
- Compliance or fraud hold on the transfer
3. transfer.reversed - The transfer initially succeeded (money left your balance), but the bank reversed it. The money comes back to your Paystack balance. This is rare but happens in cases like:
- The recipient's bank discovers the account is flagged
- A bank reconciliation process identifies an error
- Regulatory action on the recipient's account
Your database should track these states explicitly. A transfer record should have a status column that transitions through: pending (created via API), success (webhook received), failed (webhook received), or reversed (webhook received after a previous success).
Transfer Event Payloads
All three transfer events share a similar payload structure. Here is a transfer.success example:
{
"event": "transfer.success",
"data": {
"id": 5678901,
"domain": "live",
"amount": 1500000,
"currency": "NGN",
"reference": "PAYOUT-2026-07-20-xyz789",
"source": "balance",
"reason": "Vendor payment for July invoice",
"status": "success",
"transfer_code": "TRF_abc123def456",
"recipient": {
"active": true,
"currency": "NGN",
"description": "Vendor - Adeola Ltd",
"domain": "live",
"email": "finance@adeola.co",
"id": 3456789,
"name": "Adeola Industries Ltd",
"recipient_code": "RCP_abc123",
"type": "nuban",
"details": {
"account_number": "0123456789",
"account_name": "ADEOLA INDUSTRIES LTD",
"bank_code": "058",
"bank_name": "GTBank"
}
},
"session": {
"provider": "nip",
"id": "session_abc123"
},
"created_at": "2026-07-20T09:00:00.000Z",
"updated_at": "2026-07-20T09:02:30.000Z",
"complete_message": "",
"failures": null
}
}
Key fields:
data.reference- The transfer reference you provided when initiating the transfer. This is your primary lookup key.data.transfer_code- Paystack's identifier for the transfer. Useful for API calls.data.amount- Transfer amount in smallest currency unit.data.status- "success", "failed", or "reversed".data.recipient- Full details of the recipient, including account number, bank name, and recipient code.data.reason- The description you provided when creating the transfer.data.complete_message- Bank-provided message about the transfer result. May contain failure or reversal reasons.data.failures- Details about why the transfer failed (for transfer.failed events).
For transfer.failed, the data.complete_message or data.failures field usually contains the reason. For transfer.reversed, the status will be "reversed" and the complete_message may explain why the bank reversed it.
The Transfer Event Dispatcher
Here is a complete handler that routes all three transfer events to the appropriate processing function:
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
async (req, res) => {
// Verify signature
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
const hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
// Return 200 immediately
res.status(200).send('OK');
const payload = JSON.parse(req.body.toString());
try {
switch (payload.event) {
case 'transfer.success':
await handleTransferSuccess(payload.data);
break;
case 'transfer.failed':
await handleTransferFailed(payload.data);
break;
case 'transfer.reversed':
await handleTransferReversed(payload.data);
break;
case 'charge.success':
await handleChargeSuccess(payload.data);
break;
default:
console.log('Unhandled event: ' + payload.event);
}
} catch (err) {
console.error(
'Error processing ' + payload.event + ': ' + err.message
);
// Do not throw. We already returned 200.
// Log the error and investigate manually.
}
}
);
async function handleTransferSuccess(data) {
const { reference, amount, transfer_code } = data;
const recipientName = data.recipient ? data.recipient.name : 'Unknown';
console.log(
'Transfer success: ' + reference +
', amount: ' + amount +
', recipient: ' + recipientName
);
// Update the payout record (idempotent)
const result = await db.query(
'UPDATE payouts SET status = $1, completed_at = NOW(), ' +
'transfer_code = $2 ' +
'WHERE reference = $3 AND status = $4',
['success', transfer_code, reference, 'pending']
);
if (result.rowCount === 0) {
console.log(
'Payout already processed or not found: ' + reference
);
}
}
async function handleTransferFailed(data) {
const { reference, amount } = data;
const reason = data.complete_message || 'No reason provided';
console.error(
'Transfer failed: ' + reference +
', amount: ' + amount +
', reason: ' + reason
);
// Update status to failed
const result = await db.query(
'UPDATE payouts SET status = $1, failure_reason = $2, ' +
'failed_at = NOW() ' +
'WHERE reference = $3 AND status = $4',
['failed', reason, reference, 'pending']
);
if (result.rowCount > 0) {
// Optionally: queue for retry or notify ops team
await notifyOpsTeam({
type: 'transfer_failed',
reference: reference,
amount: amount,
reason: reason,
});
}
}
async function handleTransferReversed(data) {
const { reference, amount } = data;
const reason = data.complete_message || 'No reason provided';
console.error(
'Transfer REVERSED: ' + reference +
', amount: ' + amount +
', reason: ' + reason
);
// Update from success to reversed
const result = await db.query(
'UPDATE payouts SET status = $1, reversal_reason = $2, ' +
'reversed_at = NOW() ' +
'WHERE reference = $3 AND status = $4',
['reversed', reason, reference, 'success']
);
if (result.rowCount > 0) {
// Always notify ops team for reversals
await notifyOpsTeam({
type: 'transfer_reversed',
reference: reference,
amount: amount,
reason: reason,
urgent: true,
});
}
}
Notice the idempotency pattern: each UPDATE includes AND status = 'pending' (or AND status = 'success' for reversals). This prevents processing the same event twice and prevents state regression (you cannot go from "success" back to "pending").
Retry Logic for Failed Transfers
When a transfer fails, you have a decision to make: retry automatically, retry manually, or give up.
When to retry automatically:
- Transient bank errors (the failure reason suggests a temporary issue)
- Timeout or network errors on the bank side
- The recipient account details are confirmed valid
When NOT to retry:
- Invalid account number (retrying the same wrong number will always fail)
- Account is closed or dormant
- Compliance or fraud holds
- Insufficient balance (fix the balance first)
Here is a retry pattern with a maximum attempt count:
async function handleTransferFailed(data) {
const { reference, amount } = data;
const reason = data.complete_message || '';
// Update status
await db.query(
'UPDATE payouts SET status = $1, failure_reason = $2, ' +
'retry_count = retry_count + 1, failed_at = NOW() ' +
'WHERE reference = $3 AND status = $4',
['failed', reason, reference, 'pending']
);
// Check if we should retry
const payout = await db.query(
'SELECT id, retry_count, recipient_code, amount FROM payouts WHERE reference = $1',
[reference]
);
if (payout.rows.length === 0) return;
const row = payout.rows[0];
const MAX_RETRIES = 3;
// Do not retry if max retries reached
if (row.retry_count >= MAX_RETRIES) {
console.log('Max retries reached for ' + reference);
await notifyOpsTeam({
type: 'transfer_max_retries',
reference: reference,
amount: amount,
reason: reason,
});
return;
}
// Do not retry for permanent failures
const permanentFailures = [
'invalid account',
'account closed',
'dormant account',
];
const isPermanent = permanentFailures.some(function(keyword) {
return reason.toLowerCase().indexOf(keyword) !== -1;
});
if (isPermanent) {
console.log('Permanent failure for ' + reference + ': ' + reason);
return;
}
// Schedule a retry with exponential backoff
const delayMinutes = Math.pow(2, row.retry_count) * 5; // 5, 10, 20 minutes
console.log(
'Scheduling retry for ' + reference +
' in ' + delayMinutes + ' minutes'
);
// Generate a new reference for the retry
const retryReference = reference + '-retry-' + row.retry_count;
await retryQueue.add(
'transfer-retry',
{
recipientCode: row.recipient_code,
amount: row.amount,
reference: retryReference,
originalReference: reference,
},
{ delay: delayMinutes * 60 * 1000 }
);
// Update status back to pending for the retry
await db.query(
'UPDATE payouts SET status = $1, reference = $2 WHERE id = $3',
['pending', retryReference, row.id]
);
}
Important: each retry uses a new reference. Paystack requires unique references. If you reuse the original reference, the transfer API will reject it.
Handling Reversals
Reversals are the trickiest transfer event because they change a previously completed payout. When you receive transfer.reversed, the following has happened:
- You initiated a transfer. Status: pending.
- You received
transfer.success. Status: success. You told the user their withdrawal was complete. - Hours or days later, the bank reversed the transfer. The money returned to your Paystack balance. Status: reversed.
What your handler must do:
1. Update the payout record to "reversed." This is the database change shown in the dispatcher code above.
2. Decide what to do with the money. The funds are back in your Paystack balance. Options:
- Credit the user's in-app balance (if you have a wallet system) so they can request another withdrawal.
- Automatically retry the transfer to the same account (risky, the bank reversed it for a reason).
- Hold the funds and ask the user to provide new bank details.
- Flag for manual review by your ops team.
3. Notify the user. The user thinks their withdrawal was successful. They need to know it was reversed. Send an email or in-app notification explaining the situation and what to do next.
4. Notify your ops team. Reversals are unusual. Someone should investigate why the bank reversed the transfer. It could indicate a fraud issue, a compliance problem, or a data entry error.
async function handleTransferReversed(data) {
const { reference, amount } = data;
const reason = data.complete_message || 'Bank reversal';
// 1. Update payout status
const result = await db.query(
'UPDATE payouts SET status = $1, reversal_reason = $2, ' +
'reversed_at = NOW() ' +
'WHERE reference = $3 AND status = $4 ' +
'RETURNING user_id, amount',
['reversed', reason, reference, 'success']
);
if (result.rows.length === 0) {
console.log('Reversal for unknown or already-reversed payout: ' + reference);
return;
}
const payout = result.rows[0];
// 2. Credit the user's wallet balance
await db.query(
'UPDATE wallets SET balance = balance + $1 WHERE user_id = $2',
[payout.amount, payout.user_id]
);
// 3. Notify the user
await sendNotification(payout.user_id, {
type: 'transfer_reversed',
message: 'Your withdrawal of ' + (payout.amount / 100) +
' was reversed by the bank. The amount has been ' +
'credited back to your wallet balance.',
reference: reference,
});
// 4. Alert the ops team
await notifyOpsTeam({
type: 'transfer_reversed',
reference: reference,
amount: payout.amount,
userId: payout.user_id,
reason: reason,
urgent: true,
});
}
Database Schema for Transfer Tracking
Here is a minimal schema that supports the full transfer lifecycle:
CREATE TABLE payouts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
reference VARCHAR(255) UNIQUE NOT NULL,
transfer_code VARCHAR(255),
recipient_code VARCHAR(255) NOT NULL,
amount INTEGER NOT NULL, -- smallest currency unit
currency VARCHAR(3) NOT NULL DEFAULT 'NGN',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
failure_reason TEXT,
reversal_reason TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
reversed_at TIMESTAMPTZ,
CONSTRAINT valid_status CHECK (
status IN ('pending', 'success', 'failed', 'reversed')
)
);
-- Index for webhook lookups
CREATE INDEX idx_payouts_reference ON payouts(reference);
-- Index for finding payouts to retry
CREATE INDEX idx_payouts_status_retry ON payouts(status, retry_count)
WHERE status = 'failed';
The UNIQUE constraint on reference prevents duplicate payout records. The CHECK constraint ensures only valid statuses are stored. The partial index on failed payouts makes retry queries fast.
For audit purposes, you may also want a payout_events table that logs every webhook event received for each payout, including the raw payload. See storing raw webhook payloads for audit.
Edge Cases
Webhook arrives before you save the payout record. If your transfer API call succeeds but your database insert fails (or has not committed yet), the webhook arrives and finds no matching record. Solution: save the payout record in a database transaction before calling the Transfer API. If the API call fails, roll back the record.
Multiple webhooks for the same transfer. Paystack can send the same event more than once (retries). Your idempotent handler (with the AND status = 'pending' guard) handles this. The first one updates the record. Subsequent ones match zero rows and do nothing.
Reversal arrives before success. In rare cases, network delays might cause a reversal webhook to arrive before the success webhook. Your handler should account for this. If the payout is still "pending" when a reversal arrives, update it to "reversed" directly. Add 'pending' to the status check in the reversal handler.
Transfer stuck in pending. Sometimes a transfer stays pending for hours (bank-side delays). Build a reconciliation job that checks old pending transfers against the Paystack Verify Transfer API and updates their status. This catches cases where the webhook was lost or never sent.
Partial failures in bulk transfers. If you use Paystack's Bulk Transfer feature, individual transfers within the batch can succeed or fail independently. You will receive separate webhook events for each transfer in the batch.
This article is part of the Paystack webhooks engineering guide. For the full catalog of events, see every Paystack webhook event explained.
Key Takeaways
- ✓Transfers are asynchronous. The API returns "pending" and the webhook delivers the final result.
- ✓transfer.success means money left your Paystack balance and reached the recipient's bank account.
- ✓transfer.failed means the transfer did not go through. The money remains in your Paystack balance. Common causes: invalid account number, bank rejection.
- ✓transfer.reversed means a previously successful transfer was reversed by the bank. The money returns to your Paystack balance.
- ✓Track transfer state transitions in your database: pending, success, failed, reversed. Never skip a state.
- ✓For failed transfers, implement retry logic with a maximum retry count. Do not retry indefinitely.
- ✓Reversals are rare but serious. Notify your operations team and update your records. The recipient did not keep the money.
Frequently Asked Questions
- Why can't I just check the Transfer API response to see if a transfer succeeded?
- The Transfer API returns a "pending" status when you initiate a transfer. The actual outcome depends on the recipient's bank, which processes the transfer asynchronously. The bank might accept, reject, or even reverse the transfer after initial acceptance. The webhook is how Paystack communicates the final result. You can also poll the Verify Transfer API, but webhooks give you near-real-time updates without polling.
- How long does it take for a transfer webhook to arrive after initiating the transfer?
- It varies. Some transfers complete in seconds (especially within the same bank or through instant payment rails). Others take minutes or hours, depending on the bank and the transfer amount. Reversals can happen hours or days after the initial success. There is no guaranteed timeline.
- Should I retry a failed transfer automatically?
- It depends on the failure reason. Transient bank errors and timeouts are worth retrying. Invalid account numbers, closed accounts, and compliance holds should not be retried because they will fail again. Always set a maximum retry count (2 or 3 is reasonable) and use exponential backoff. For each retry, generate a new unique reference.
- What should I tell the user when a transfer is reversed?
- Be transparent. Tell them the withdrawal was reversed by the bank and the funds have been returned to their wallet balance. Do not promise to retry automatically unless you have confirmed the reversal reason. Reversals often indicate an issue with the recipient account, so asking the user to verify their bank details is a good next step.
- Can a transfer go from "failed" to "success"?
- No. Once a transfer fails, that is the final state for that transfer reference. If you want to retry, create a new transfer with a new reference. Paystack will not send a transfer.success event for a reference that previously received transfer.failed. The same applies in reverse: a transfer cannot go from success to failed (but it can go from success to reversed).
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