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.
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.
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:
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:
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).
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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").
When a transfer fails, you have a decision to make: retry automatically, retry manually, or give up.
When to retry automatically:
When NOT to retry:
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.
Reversals are the trickiest transfer event because they change a previously completed payout. When you receive transfer.reversed, the following has happened:
transfer.success. Status: success. You told the user their withdrawal was complete.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:
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,
});
}
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.
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.
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