To initiate a single transfer on Paystack, POST to /transfer with source set to "balance", the amount in the smallest currency unit, a recipient_code, and a unique reference. If OTP is enabled, finalize with /transfer/finalize_transfer. Track the outcome through transfer.success and transfer.failed webhook events.
A single transfer sends money from your Paystack balance to exactly one recipient. You need three things before you can call the API: a funded balance, a recipient code (created via the transfer recipient endpoint), and a unique reference.
const initiateSingleTransfer = async (recipientCode, amountInMinorUnit, reason, reference) => {
const response = 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: amountInMinorUnit,
recipient: recipientCode,
reason: reason,
reference: reference,
}),
});
const data = await response.json();
if (!data.status) {
throw new Error('Transfer failed: ' + data.message);
}
return {
transferCode: data.data.transfer_code,
status: data.data.status,
reference: data.data.reference,
};
};
The source field is always "balance". There is no other option. The amount is in the smallest currency unit: kobo for NGN (1 NGN = 100 kobo), pesewas for GHS (1 GHS = 100 pesewas), cents for KES (1 KES = 100 cents). If you want to send 5,000 Naira, pass 500000.
The reason field is optional but valuable. It shows up in the recipient's bank statement (though some banks truncate it) and in your Paystack dashboard. Use it for reconciliation. Something like "July vendor payout - Order #4521" tells you exactly what the transfer was for when you are debugging three months later.
The reference field is the most important field in the entire request. It serves two purposes: it uniquely identifies the transfer in your system, and it prevents duplicate payments on Paystack's side. If you send two transfer requests with the same reference, Paystack treats the second one as a duplicate and returns the existing transfer.
Good reference patterns are deterministic. Given the same inputs, you generate the same reference.
// Pattern 1: Payout record ID (simplest and safest)
function makeReference(payoutId) {
return 'payout_' + payoutId;
}
// e.g., payout_8847
// Pattern 2: Entity + period (prevents paying the same person twice for the same period)
function makeVendorReference(vendorId, period) {
return 'vendor_' + vendorId + '_' + period;
}
// e.g., vendor_42_2026-07
// Pattern 3: Order-based (for order-triggered payouts)
function makeOrderReference(orderId) {
return 'order_payout_' + orderId;
}
// e.g., order_payout_10542
Avoid references that include random components like timestamps or UUIDs unless they come from a database record you created first. If your reference is "payout_" + Date.now() and the first request fails silently (network timeout, no response), your retry generates a different reference and you send the money twice.
The safest pattern: create a payout record in your database first, get its auto-incremented ID, use that ID as the reference, then call Paystack. If anything goes wrong and you retry, the same database record produces the same reference. For a deep dive into idempotency patterns, see preventing duplicate payouts.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Getting the amount wrong is one of the most dangerous bugs in payment code. If your code confuses Naira with kobo, you send 100x the intended amount. There is no undo for a successful transfer.
The rules are simple but need discipline:
500000.50000.1000000.100000.// Conversion helper
function toMinorUnit(amount, currency) {
var multipliers = {
NGN: 100,
GHS: 100,
KES: 100,
ZAR: 100,
};
var multiplier = multipliers[currency];
if (!multiplier) {
throw new Error('Unsupported currency: ' + currency);
}
return Math.round(amount * multiplier);
}
// Usage
var amountInKobo = toMinorUnit(5000, 'NGN'); // 500000
var amountInCents = toMinorUnit(10000, 'KES'); // 1000000
Use Math.round rather than simple multiplication to avoid floating-point issues. In JavaScript, 19.99 * 100 gives 1998.9999999999998, not 1999. Rounding fixes this. Better yet, store and work with amounts in the minor unit throughout your application and only convert to the major unit for display.
If OTP is enabled on your Paystack account (which is the default), the transfer response comes back with status: "otp". Paystack sends a one-time password to the phone number or email registered on your Paystack business account. You must submit it to finalize the transfer.
const finalizeTransfer = async (transferCode, otp) => {
const response = await fetch('https://api.paystack.co/transfer/finalize_transfer', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
transfer_code: transferCode,
otp: otp,
}),
});
const data = await response.json();
return data;
};
The OTP flow is designed for scenarios where a human is initiating the transfer from a dashboard or admin panel. They click "send payout", Paystack sends them the OTP, they type it in, your code calls finalize, and the transfer proceeds.
For automated systems (cron-based payouts, webhook-triggered settlements), OTP is impractical because no human is present to enter the code. You can disable OTP through the dashboard or API. See transfer OTP: enabling, disabling, and automating for the full process and security implications.
If you leave OTP enabled but do not submit the code, the transfer sits in the otp state until it expires. The money remains reserved in your balance during this time. Expired OTP transfers eventually release the funds back to your available balance.
After you initiate a transfer, it moves through statuses: otp (if OTP is enabled), pending (processing through the banking network), and then either success, failed, or (rarely) reversed.
The correct way to track status is through webhooks, not polling. Paystack pushes status changes to your webhook URL as they happen.
// Webhook handler for single transfer outcomes
app.post('/webhooks/paystack', async (req, res) => {
// Always respond 200 immediately
res.sendStatus(200);
var event = req.body;
var reference = event.data.reference;
// Find the payout record in your database
var payout = await db.payouts.findByReference(reference);
if (!payout) {
console.error('Unknown reference: ' + reference);
return;
}
if (event.event === 'transfer.success') {
await db.payouts.update(payout.id, {
status: 'completed',
completedAt: new Date(event.data.updated_at),
});
// Notify the recipient if needed
await notifyRecipient(payout.recipientId, 'Your payout has been sent.');
}
if (event.event === 'transfer.failed') {
await db.payouts.update(payout.id, {
status: 'failed',
failureReason: event.data.reason,
failedAt: new Date(event.data.updated_at),
});
// Decide whether to retry based on the reason
await evaluateRetry(payout, event.data.reason);
}
if (event.event === 'transfer.reversed') {
await db.payouts.update(payout.id, {
status: 'reversed',
reversedAt: new Date(event.data.updated_at),
});
// Alert operations team
await alertOps('Transfer reversed: ' + reference);
}
});
If you need to check a transfer status manually (for debugging or reconciliation), use the fetch endpoint:
const getTransferStatus = async (transferIdOrCode) => {
var response = await fetch(
'https://api.paystack.co/transfer/' + transferIdOrCode,
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var data = await response.json();
return data.data.status;
};
Use this for debugging, not as your primary status tracking. Polling the API in a loop wastes rate limit quota and is slower than webhooks.
Not all failures are equal. Some are transient (the bank was down for ten minutes) and resolve on retry. Others are permanent (the account does not exist) and will fail every time. Your retry logic must distinguish between them.
// Classify failure reasons
function isRetryable(failureReason) {
var transientPatterns = [
'timeout',
'temporarily unavailable',
'network',
'service unavailable',
'try again',
'system error',
];
var lowerReason = (failureReason || '').toLowerCase();
for (var i = 0; i < transientPatterns.length; i++) {
if (lowerReason.indexOf(transientPatterns[i]) !== -1) {
return true;
}
}
return false;
}
// Retry handler
async function evaluateRetry(payout, failureReason) {
if (!isRetryable(failureReason)) {
// Permanent failure, do not retry
await db.payouts.update(payout.id, {
requiresManualReview: true,
reviewNote: 'Permanent failure: ' + failureReason,
});
return;
}
// Check retry count
if (payout.retryCount >= 3) {
await db.payouts.update(payout.id, {
requiresManualReview: true,
reviewNote: 'Max retries exceeded. Last reason: ' + failureReason,
});
return;
}
// Schedule retry with exponential backoff
var delayMinutes = Math.pow(2, payout.retryCount) * 15; // 15, 30, 60 min
await db.payouts.update(payout.id, {
status: 'retry_scheduled',
retryCount: payout.retryCount + 1,
retryAt: new Date(Date.now() + delayMinutes * 60 * 1000),
});
}
The key principle: never retry blindly. Check the reason, cap the retry count, and add increasing delays between attempts. Bank downtime issues usually resolve within minutes to hours, so three retries with exponential backoff (15 minutes, 30 minutes, 60 minutes) covers most transient failures without being aggressive.
For detailed patterns on handling failures and the rare reversal case, see managing transfer failures and reversals.
Putting it all together, here is the full flow from payout request to completion.
async function processPayout(payoutId) {
// Step 1: Load the payout record
var payout = await db.payouts.findById(payoutId);
if (payout.status !== 'approved') {
throw new Error('Payout is not in approved state');
}
// Step 2: Check balance
var balanceResponse = await fetch('https://api.paystack.co/balance', {
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY },
});
var balances = (await balanceResponse.json()).data;
var available = balances.find(function(b) { return b.currency === payout.currency; });
if (!available || available.balance < payout.amountMinorUnit) {
await db.payouts.update(payoutId, {
status: 'insufficient_balance',
note: 'Available: ' + (available ? available.balance : 0),
});
return;
}
// Step 3: Build reference from payout ID
var reference = 'payout_' + payout.id;
// Step 4: Initiate the transfer
try {
var result = await initiateSingleTransfer(
payout.recipientCode,
payout.amountMinorUnit,
payout.reason || 'Payout',
reference
);
// Step 5: Update payout record to processing
await db.payouts.update(payoutId, {
status: 'processing',
transferCode: result.transferCode,
paystackStatus: result.status,
initiatedAt: new Date(),
});
} catch (err) {
await db.payouts.update(payoutId, {
status: 'initiation_failed',
note: err.message,
});
}
// Step 6: Wait for webhook (handled by the webhook endpoint)
}
This flow covers the happy path and the most common failure mode (insufficient balance). The webhook handler covers the rest. Your payout lifecycle should look like: created -> approved -> processing -> completed (or failed, reversed).
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