This guide sends money from your Paystack balance to mobile money wallets in Ghana (MTN, Telecel, AT) and Kenya (M-Pesa) with Paystack’s Transfers API, from Node.js: refunds, seller payouts, salaries. You’ll build:
- a
paystack-transfers.jsclient: save a recipient, start a transfer, look one up, - a
payouts.jsmodule that never pays twice, even after a timeout or a crash, and handles reversals, - a webhook that checks
x-paystack-signatureand settles payouts only from the looked-up transfer, - a test that runs every case without calling Paystack.
It uses Node’s built-in fetch (Node 18+). Every code block was run in a fresh project, the client was checked against a fake Paystack, and the test at the end passes. API facts come from Paystack’s transfer documentation, checked in November 2026. To receive mobile money, see Paystack mobile money in Ghana and Kenya.
How a payout works
- Once per customer: create a transfer recipient for their wallet and store the
recipient_code(RCP_…). - You save the payout with your own
reference, then callPOST /transfer. - Paystack answers
"Transfer has been queued". The status ispending(orotpif transfers need an OTP). That is not a payout yet. - Paystack sends
transfer.success,transfer.failedortransfer.reversed. You confirm withGET /transfer/verify/:reference, the source of truth.
Two facts shape the design:
- You can look a transfer up by your own reference. So a lost response is recoverable: look it up, and if Paystack has never seen it, send it again with the same reference. Paystack’s docs say to retry with the same reference to avoid double crediting.
successisn’t the end. Paystack says a successful transfer “doesn’t equate to instant credit”, and a transfer can later bereversed, with the money returned to your balance. A paid payout must still be able to become reversed.
Before you start
- A registered business. Paystack says only registered businesses can use transfers. A starter business must be upgraded first.
- Turn off OTP for API transfers. Transfers need an OTP by default. In the dashboard’s Preferences, untick Confirm transfers before sending. Otherwise every transfer waits for an OTP and is abandoned after 30 minutes.
- Fund your balance. The balance must cover the amount plus Paystack’s transfer fee; both are deducted when the transfer is created.
- Set your webhook URL under Settings → API Keys & Webhooks. Paystack signs webhooks with your secret key; there’s no separate webhook secret.
- Create a
.envfile (load it withnode --env-file=.env) and never commit it:
PAYSTACK_SECRET_KEY=sk_test_your_secret_key
Install Express with npm install express, and set "type": "module" in package.json (npm pkg set type=module). In test mode, Paystack says transfers always succeed.
The transfers client
// paystack-transfers.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const BASE = 'https://api.paystack.co';
export class PaystackError extends Error {
constructor(message, { status = 0, ambiguous = false, body = null } = {}) {
super(message);
this.status = status;
this.ambiguous = ambiguous; // true: we don't know what happened. Look the transfer up before doing anything else.
this.body = body;
}
}
async function call(path, init = {}) {
let res;
try {
res = await fetch(`${BASE}${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`, 'Content-Type': 'application/json', ...init.headers },
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
throw new PaystackError(`Paystack unreachable: ${err.message}`, { ambiguous: true });
}
const body = await res.json().catch(() => null);
if (res.status >= 500 || !body) throw new PaystackError(`Paystack HTTP ${res.status}`, { status: res.status, ambiguous: true, body });
return { res, body };
}
/** Saves a mobile money wallet as a transfer recipient. Returns the recipient_code: store it with the customer. */
export async function createRecipient({ name, phone, bankCode, currency }) {
const { res, body } = await call('/transferrecipient', {
method: 'POST',
body: JSON.stringify({ type: 'mobile_money', name, account_number: phone, bank_code: bankCode, currency }),
});
if (res.ok && body.status === true && body.data?.recipient_code) return body.data.recipient_code;
throw new PaystackError(`Recipient refused: ${body.message ?? res.status}`, { status: res.status, body });
}
/** Starts a transfer from your Paystack balance. Returns Paystack's data: transfer_code, reference, status. */
export async function initiateTransfer({ reference, amount, currency, recipient, reason }) {
const { res, body } = await call('/transfer', {
method: 'POST',
body: JSON.stringify({
source: 'balance',
amount, // in the currency's subunit: pesewas for GHS, cents for KES
currency,
recipient, // the recipient_code
reason: String(reason).slice(0, 100),
reference, // your own reference, saved before this call
}),
});
if (res.ok && body.status === true && body.data) return body.data;
throw new PaystackError(`Transfer refused: ${body.message ?? res.status}`, { status: res.status, body });
}
/** The source of truth for a transfer reference, or null if Paystack has no such transfer. */
export async function verifyTransfer(reference) {
const { res, body } = await call(`/transfer/verify/${encodeURIComponent(reference)}`);
if (res.ok && body.status === true && body.data) return body.data;
if (res.status === 404) return null;
throw new PaystackError(`Transfer lookup failed: ${res.status}`, { status: res.status, ambiguous: true, body });
}
/** x-paystack-signature is an HMAC SHA512 of the raw request body, keyed with your secret key. */
export function signatureOk(rawBody, signature, secret = process.env.PAYSTACK_SECRET_KEY ?? '') {
if (!secret || typeof signature !== 'string') return false;
const expected = Buffer.from(createHmac('sha512', secret).update(rawBody).digest('hex'));
const given = Buffer.from(signature);
return given.length === expected.length && timingSafeEqual(given, expected);
}
Recipients. For a personal wallet, type is mobile_money, account_number is the phone number, and bank_code is the network: MTN, VOD (Telecel, formerly Vodafone) or ATL (AT, formerly AirtelTigo) in Ghana, and MPESA in Kenya. You can list the codes for your account with GET /bank?currency=GHS&type=mobile_money. Paystack’s example writes the Ghana number in local format (0551234987); the docs don’t state the format for Kenya, so this guide uses the local format there too. Confirm it with one small live payout. Kenyan Till and PayBill numbers use type mobile_money_business with MPTILL or MPPAYBILL; they aren’t covered here.
Amounts are in the currency’s subunit: pesewas for GHS, cents for KES. 2550 is 25.50.
Never pay twice
new ──send──▶ sending ──queued──▶ pending ──verify: success──▶ paid ──verify: reversed──▶ reversed
│ └──verify: failed / abandoned / blocked / rejected──▶ failed
├──refused, and verify finds nothing──▶ failed
└──timeout / 5xx──▶ unknown ──verify finds it──▶ pending / paid / failed
└──verify: never seen──▶ sent again, same reference
// payouts.js
import { randomUUID } from 'node:crypto';
import { initiateTransfer, PaystackError, verifyTransfer } from './paystack-transfers.js';
const paystack = { initiateTransfer, verifyTransfer };
const FINAL = ['failed', 'reversed', 'review'];
// Mobile money bank codes from Paystack's transfer recipient docs: Ghana MTN, VOD (Telecel), ATL; Kenya MPESA.
// Prefixes from Google libphonenumber's carrier data. Longest prefix wins.
const NETWORKS = {
GHS: { cc: '233', prefixes: { 24: 'MTN', 25: 'MTN', 53: 'MTN', 54: 'MTN', 55: 'MTN', 59: 'MTN', 20: 'VOD', 50: 'VOD', 23: 'ATL', 26: 'ATL', 27: 'ATL', 56: 'ATL', 57: 'ATL' } },
KES: { cc: '254', prefixes: { 11: 'MPESA', 14: 'MPESA', 18: 'MPESA', 70: 'MPESA', 71: 'MPESA', 72: 'MPESA', 74: 'MPESA', 744: null, 747: null, 757: 'MPESA', 758: 'MPESA', 759: 'MPESA', 768: 'MPESA', 769: 'MPESA', 79: 'MPESA' } },
};
/** '+233 55 123 4987' or '0551234987' -> { phone: '0551234987', bankCode: 'MTN' }. */
export function wallet(raw, currency) {
const net = NETWORKS[currency];
if (!net) throw new Error('Paystack mobile money payouts are GHS or KES');
let digits = String(raw).replace(/\D/g, '');
if (digits.startsWith(net.cc)) digits = digits.slice(net.cc.length);
if (digits.startsWith('0')) digits = digits.slice(1);
if (!/^\d{9}$/.test(digits)) throw new Error('That phone number has the wrong number of digits');
const prefix = Object.keys(net.prefixes).filter((p) => digits.startsWith(p)).sort((a, b) => b.length - a.length)[0];
const bankCode = prefix ? net.prefixes[prefix] : null;
if (!bankCode) throw new Error(currency === 'GHS' ? 'Enter a Ghanaian MTN, Telecel or AT number' : 'Enter a Kenyan Safaricom (M-Pesa) number');
return { phone: `0${digits}`, bankCode };
}
/** '25.50' -> 2550. Exact: no floating point. */
export function toSubunits(amount) {
const m = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(amount).trim());
if (!m) throw new Error('Amounts have at most 2 decimal places');
return Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0'));
}
/** Create the payout record. Its reference is fixed for life: Paystack treats a repeated reference as the same transfer. */
export function newPayout({ amount, currency, recipient, reason }) {
if (!['GHS', 'KES'].includes(currency)) throw new Error('Currency must be GHS or KES');
if (!/^RCP_\w+$/.test(String(recipient))) throw new Error('recipient must be a recipient_code (RCP_...)');
const subunits = toSubunits(amount);
if (subunits <= 0) throw new Error('Amount must be more than zero');
// Paystack references: lowercase letters, digits, - and _, 16 to 50 characters. A v4 UUID is 36.
return { reference: randomUUID(), amount: subunits, currency, recipient, reason, status: 'new', transferCode: null, note: null };
}
const FAILED = ['failed', 'abandoned', 'blocked', 'rejected'];
/** Applies a transfer from verifyTransfer to its payout. */
export async function settle(payout, transfer, { save }) {
if (FINAL.includes(payout.status)) return payout; // final results never change
if (!transfer || transfer.reference !== payout.reference) return payout; // not this payout
if (Number(transfer.amount) !== payout.amount || String(transfer.currency).toUpperCase() !== payout.currency) {
return save({ ...payout, status: 'review', note: `TRANSFER ${transfer.amount} ${transfer.currency}` });
}
const status = String(transfer.status ?? '').toLowerCase();
const code = transfer.transfer_code ?? payout.transferCode;
// "success" can still become "reversed" later: the money comes back to your balance.
if (status === 'reversed') return save({ ...payout, status: 'reversed', transferCode: code, note: 'REVERSED' });
if (payout.status === 'paid') return payout; // only a reversal changes a paid payout
if (status === 'success') return save({ ...payout, status: 'paid', transferCode: code, note: null });
if (FAILED.includes(status)) return save({ ...payout, status: 'failed', transferCode: code, note: status.toUpperCase() });
// pending, received, or otp (waiting for someone to approve it in the dashboard)
return save({ ...payout, status: 'pending', transferCode: code, note: status === 'otp' ? 'NEEDS_OTP' : null });
}
/**
* Sends a payout. Only "new" and "unknown" payouts are ever sent, always with the same reference.
* save(payout) must persist the record.
*/
export async function sendPayout(payout, { save, api = paystack }) {
if (!['new', 'unknown'].includes(payout.status)) return payout;
await save({ ...payout, status: 'sending' }); // saved BEFORE the network call
try {
const data = await api.initiateTransfer(payout);
// Accepted is not paid: verifyTransfer or the webhook decides. "otp" waits for approval in the dashboard.
return save({ ...payout, status: 'pending', transferCode: data.transfer_code ?? null, note: data.status === 'otp' ? 'NEEDS_OTP' : null });
} catch (err) {
if (!(err instanceof PaystackError) || err.ambiguous) {
return save({ ...payout, status: 'unknown', note: 'NO_RESPONSE' }); // checkPayout looks it up, then decides
}
// A refusal. Paystack may still have a transfer under this reference from an earlier try, so look first.
let transfer = null;
try {
transfer = await api.verifyTransfer(payout.reference);
} catch {
return save({ ...payout, status: 'unknown', note: 'NO_RESPONSE' });
}
if (transfer) return settle({ ...payout, status: 'sending' }, transfer, { save });
return save({ ...payout, status: 'failed', note: String(err.body?.message ?? `HTTP_${err.status}`).slice(0, 100) });
}
}
/** Handles a transfer.success, transfer.failed or transfer.reversed webhook whose signature you already checked. */
export async function handleWebhook(body, { find, save, api = paystack }) {
const ref = body?.data?.reference;
if (!String(body?.event ?? '').startsWith('transfer.') || !ref) return null;
const payout = await find(String(ref));
if (!payout) return null; // not ours
const transfer = await api.verifyTransfer(payout.reference); // the body only says which transfer to look up
return transfer ? settle(payout, transfer, { save }) : payout;
}
/**
* Scheduled check for payouts in flight (run it for payouts older than a minute).
* A payout Paystack has never seen is sent again with the same reference, which Paystack treats as the same transfer.
*/
export async function checkPayout(payout, { save, api = paystack }) {
if (!['sending', 'unknown', 'pending'].includes(payout.status)) return payout; // a reversal of a paid payout arrives by webhook
let transfer;
try {
transfer = await api.verifyTransfer(payout.reference);
} catch {
return payout; // Paystack unreachable: try again on the next run
}
if (transfer) return settle(payout, transfer, { save });
if (['sending', 'unknown'].includes(payout.status)) return sendPayout({ ...payout, status: 'unknown' }, { save, api });
return payout;
}
The rules:
- Create the reference once, save it, then send. Paystack wants lowercase letters, digits,
-and_, 16 to 50 characters, and recommends a v4 UUID. - Only
newandunknownpayouts are sent, and always with their own reference. A resend with the same reference is the same transfer to Paystack. - Look before you conclude. A timeout leaves the payout
unknown; the next check looks it up and only resends if Paystack has never seen it. Even a refusal is checked first: after an earlier lost response, Paystack may refuse a repeated reference because the transfer already exists. - Only the looked-up transfer counts. The webhook body only says which reference to look up; the reference, amount and currency must match the payout, or it goes to
review. failed,reversedandrevieware final.paidcan only becomereversed.
Your save function should update the row by reference and refuse to change a payout that is failed, reversed or review.
The webhook
// server.js (Express)
import express from 'express';
import { signatureOk } from './paystack-transfers.js';
import { handleWebhook } from './payouts.js';
import { find, save } from './db.js'; // your database: find(reference) and save(payout)
if (!process.env.PAYSTACK_SECRET_KEY) throw new Error('Set PAYSTACK_SECRET_KEY');
const app = express();
// The signature covers the raw bytes, so read the body raw on this route.
app.post('/webhooks/paystack', express.raw({ type: '*/*' }), async (req, res) => {
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (!signatureOk(raw, req.get('x-paystack-signature'))) return res.sendStatus(401);
let body;
try {
body = JSON.parse(raw.toString('utf8'));
} catch {
return res.sendStatus(200); // signed but not JSON: nothing to do
}
try {
await handleWebhook(body, { find, save }); // charge.* events are ignored here
res.sendStatus(200);
} catch (err) {
console.error('Paystack webhook not processed', body?.data?.reference, err);
res.sendStatus(500); // Paystack retries
}
});
app.listen(3000);
The signature covers the raw bytes, so this route reads the body with express.raw instead of express.json. The same endpoint can receive your charge.* events; handleWebhook ignores anything that isn’t transfer.*. Run checkPayout every few minutes for payouts that are sending, unknown or pending and older than a minute. Alert yourself about payouts with the note NEEDS_OTP: that means OTP is still on.
Test it without calling Paystack
This test replaces the client with a fake Paystack that, like the real one, treats a repeated reference as the same transfer. It checks that a payout is saved before it’s sent and sent only once, that the webhook body is never trusted, that a lost response is found by its reference, that a payout Paystack never saw is resent with the same reference, that a refusal is checked before failing, that a wrong amount goes to review, and that a reversal is the only thing that changes a paid payout.
// payouts.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { PaystackError, signatureOk } from './paystack-transfers.js';
import { checkPayout, handleWebhook, newPayout, sendPayout, settle, toSubunits, wallet } from './payouts.js';
function makeDb() {
const rows = new Map();
return {
rows,
find: async (ref) => rows.get(ref) ?? null,
save: async (p) => {
const old = rows.get(p.reference);
if (old && ['failed', 'reversed', 'review'].includes(old.status)) return old; // final results never change
rows.set(p.reference, p);
return p;
},
};
}
function fakePaystack() {
const transfers = new Map(); // reference -> transfer, like Paystack's side
return {
transfers,
calls: 0,
async initiateTransfer(p) {
this.calls += 1;
if (!transfers.has(p.reference)) { // the same reference is the same transfer
transfers.set(p.reference, { reference: p.reference, amount: p.amount, currency: p.currency, status: 'pending', transfer_code: `TRF_${transfers.size + 1}` });
}
return { ...transfers.get(p.reference) };
},
async verifyTransfer(ref) {
return transfers.has(ref) ? { ...transfers.get(ref) } : null;
},
};
}
const payout = (extra = {}) => newPayout({ amount: '25.50', currency: 'GHS', recipient: 'RCP_u2tnoyjjvh95pzm', reason: 'Refund for order 7', ...extra });
test('wallets, amounts and references', () => {
assert.deepEqual(wallet('+233 55 123 4987', 'GHS'), { phone: '0551234987', bankCode: 'MTN' });
assert.deepEqual(wallet('0201234567', 'GHS'), { phone: '0201234567', bankCode: 'VOD' });
assert.deepEqual(wallet('+254 710 000 000', 'KES'), { phone: '0710000000', bankCode: 'MPESA' });
assert.equal(wallet('0757123456', 'KES').bankCode, 'MPESA');
assert.throws(() => wallet('0744123456', 'KES'), /Safaricom/); // another carrier inside 074
assert.throws(() => wallet('0731234567', 'KES'), /Safaricom/); // Airtel Kenya: no bank code in the recipient docs
assert.throws(() => wallet('12345', 'GHS'), /digits/);
assert.equal(toSubunits('25.5'), 2550);
assert.equal(toSubunits('100'), 10000);
assert.throws(() => toSubunits('1.234'));
const p = payout();
assert.match(p.reference, /^[a-z0-9-]{16,50}$/);
assert.notEqual(p.reference, payout().reference);
assert.equal(p.amount, 2550);
assert.throws(() => payout({ currency: 'NGN' }), /GHS or KES/);
assert.throws(() => payout({ recipient: '0551234987' }), /recipient_code/);
});
test('saved before sending, sent once, and paid only from verify', async () => {
const db = makeDb();
const api = fakePaystack();
const real = api.initiateTransfer.bind(api);
api.initiateTransfer = async (p) => {
assert.equal(db.rows.get(p.reference)?.status, 'sending'); // saved BEFORE the call
return real(p);
};
const p = await sendPayout(payout(), { ...db, api });
assert.deepEqual([p.status, p.transferCode], ['pending', 'TRF_1']);
await sendPayout(p, { ...db, api }); // a double click sends nothing
assert.equal(api.calls, 1);
api.transfers.get(p.reference).status = 'success';
const done = await handleWebhook({ event: 'transfer.success', data: { reference: p.reference } }, { ...db, api });
assert.equal(done.status, 'paid');
});
test('the webhook body is never trusted', async () => {
const db = makeDb();
const api = fakePaystack();
const p = await sendPayout(payout(), { ...db, api });
await handleWebhook({ event: 'transfer.success', data: { reference: p.reference, status: 'success' } }, { ...db, api });
assert.equal(db.rows.get(p.reference).status, 'pending'); // Paystack still says "pending"
assert.equal(await handleWebhook({ event: 'transfer.success', data: { reference: 'not-ours' } }, { ...db, api }), null);
assert.equal(await handleWebhook({ event: 'charge.success', data: { reference: p.reference } }, { ...db, api }), null);
assert.equal(await handleWebhook({ hello: 'world' }, { ...db, api }), null);
});
test('a different amount, currency or reference is not paid', async () => {
const db = makeDb();
const api = fakePaystack();
const p = await sendPayout(payout(), { ...db, api });
const tx = { reference: p.reference, amount: 2550, currency: 'GHS', status: 'success' };
assert.equal((await settle(p, { ...tx, reference: 'someone-else' }, db)).status, 'pending');
assert.equal((await settle(p, { ...tx, amount: 255 }, db)).status, 'review');
const q = await sendPayout(payout(), { ...db, api });
assert.equal((await settle(q, { ...tx, reference: q.reference, currency: 'KES' }, db)).status, 'review');
});
test('failure states fail, otp waits, a success can still be reversed, final means final', async () => {
const db = makeDb();
const api = fakePaystack();
for (const status of ['failed', 'abandoned', 'blocked', 'rejected']) {
const p = await sendPayout(payout(), { ...db, api });
api.transfers.get(p.reference).status = status;
assert.equal((await checkPayout(p, { ...db, api })).status, 'failed');
}
const p = await sendPayout(payout(), { ...db, api });
api.transfers.get(p.reference).status = 'otp';
const waiting = await checkPayout(p, { ...db, api });
assert.deepEqual([waiting.status, waiting.note], ['pending', 'NEEDS_OTP']);
api.transfers.get(p.reference).status = 'success';
const paid = await checkPayout(db.rows.get(p.reference), { ...db, api });
assert.equal(paid.status, 'paid');
api.transfers.get(p.reference).status = 'pending'; // a stale answer can't un-pay it
assert.equal((await settle(paid, api.transfers.get(p.reference), db)).status, 'paid');
api.transfers.get(p.reference).status = 'reversed'; // but a reversal can
const reversed = await handleWebhook({ event: 'transfer.reversed', data: { reference: p.reference } }, { ...db, api });
assert.equal(reversed.status, 'reversed');
api.transfers.get(p.reference).status = 'success';
await handleWebhook({ event: 'transfer.success', data: { reference: p.reference } }, { ...db, api });
assert.equal(db.rows.get(p.reference).status, 'reversed');
});
test('final payouts ignore later answers, and an OTP request is flagged', async () => {
const saved = [];
const save = async (p) => (saved.push(p), p); // a save that guards nothing: settle itself must refuse
for (const status of ['failed', 'reversed', 'review']) {
const p = { ...payout(), status };
assert.equal((await settle(p, { reference: p.reference, amount: 2550, currency: 'GHS', status: 'success' }, { save })).status, status);
}
assert.equal(saved.length, 0);
const api = fakePaystack();
const otp = { ...api, async initiateTransfer(p) { return { reference: p.reference, status: 'otp', transfer_code: 'TRF_otp' }; } };
const p = await sendPayout(payout(), { save, api: otp });
assert.deepEqual([p.status, p.note], ['pending', 'NEEDS_OTP']);
});
test('a timeout after Paystack created the transfer: found by reference, never sent twice', async () => {
const db = makeDb();
const api = fakePaystack();
const real = api.initiateTransfer.bind(api);
api.initiateTransfer = async (p) => {
await real(p); // Paystack created it...
throw new PaystackError('Paystack unreachable: timeout', { ambiguous: true }); // ...but we never heard back
};
const p = await sendPayout(payout(), { ...db, api });
assert.equal(p.status, 'unknown');
api.transfers.get(p.reference).status = 'success';
assert.equal((await checkPayout(p, { ...db, api })).status, 'paid');
assert.equal(api.calls, 1);
});
test('a timeout before Paystack saw it: resent with the same reference, one transfer in total', async () => {
const db = makeDb();
const api = fakePaystack();
const real = api.initiateTransfer.bind(api);
let first = true;
api.initiateTransfer = async (p) => {
if (first) { first = false; throw new PaystackError('Paystack HTTP 503', { status: 503, ambiguous: true }); }
return real(p);
};
const p = await sendPayout(payout(), { ...db, api });
assert.equal(p.status, 'unknown');
const again = await checkPayout(p, { ...db, api });
assert.deepEqual([again.status, again.reference, api.transfers.size], ['pending', p.reference, 1]);
await checkPayout(again, { ...db, api });
assert.equal(api.transfers.size, 1);
const crashed = { ...payout(), status: 'sending' }; // the process died during the call
await db.save(crashed);
assert.equal((await checkPayout(crashed, { ...db, api })).status, 'pending');
assert.equal(api.transfers.size, 2);
});
test('a refusal fails only when Paystack has no transfer under the reference', async () => {
const db = makeDb();
const api = fakePaystack();
const broke = { ...api, async initiateTransfer() { throw new PaystackError('Transfer refused: Your balance is not enough', { status: 400, body: { status: false, message: 'Your balance is not enough to fulfil this request' } }); } };
const p = await sendPayout(payout(), { ...db, api: broke });
assert.deepEqual([p.status, p.note], ['failed', 'Your balance is not enough to fulfil this request']);
const q = payout();
api.transfers.set(q.reference, { reference: q.reference, amount: 2550, currency: 'GHS', status: 'success', transfer_code: 'TRF_x' });
const dup = { ...api, async initiateTransfer() { throw new PaystackError('Transfer refused: duplicate', { status: 400, body: { status: false } }); } };
assert.equal((await sendPayout({ ...q, status: 'unknown' }, { ...db, api: dup })).status, 'paid'); // it went through earlier
const down = { ...broke, async verifyTransfer() { throw new PaystackError('down', { ambiguous: true }); } };
assert.equal((await sendPayout(payout(), { ...db, api: down })).status, 'unknown');
});
test('an unreachable Paystack changes nothing', async () => {
const db = makeDb();
const api = fakePaystack();
const p = await sendPayout(payout(), { ...db, api });
const down = { ...api, async verifyTransfer() { throw new PaystackError('down', { ambiguous: true }); } };
assert.equal((await checkPayout(p, { ...db, api: down })).status, 'pending');
await assert.rejects(handleWebhook({ event: 'transfer.success', data: { reference: p.reference } }, { ...db, api: down }));
});
test('the webhook signature is an HMAC SHA512 of the raw body', () => {
const body = Buffer.from('{"event":"transfer.success","data":{"reference":"x"}}');
const sig = createHmac('sha512', 'sk_test_secret').update(body).digest('hex');
assert.equal(signatureOk(body, sig, 'sk_test_secret'), true);
assert.equal(signatureOk(body, sig, 'sk_test_other'), false);
assert.equal(signatureOk(Buffer.from(body.toString().replace('x', 'y')), sig, 'sk_test_secret'), false);
assert.equal(signatureOk(body, undefined, 'sk_test_secret'), false);
assert.equal(signatureOk(body, createHmac('sha512', '').update(body).digest('hex'), ''), false);
});
Run it with node --test:
node --test payouts.test.js
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Every transfer is refused | The business isn’t registered, or transfers aren’t enabled on the account | Complete Paystack’s business verification |
Payouts stay pending with NEEDS_OTP |
OTP is still on | Untick Confirm transfers before sending in Preferences |
failed with a balance message |
The balance can’t cover the amount plus the fee | Fund the balance before a payout run |
| Recipient refused | Wrong bank_code for the number, or wrong currency |
Check the network; list codes with GET /bank?currency=GHS&type=mobile_money |
Webhooks get 401 |
The body was parsed before the signature check, or the wrong key | Read the raw body; use the secret key of the same mode (test or live) |
review |
The transfer’s amount or currency differs from the payout | Look at it in the dashboard |
A paid payout becomes reversed |
The wallet couldn’t receive the money | The money is back in your balance; contact the recipient, then create a new payout |
Going to production
- Switch to your live secret key (
sk_live_…). It also signs the live webhooks. - Protect the code that creates payouts. Anyone who can call it can send your money. Require an authenticated admin, set limits per payout and per day, and log who created each one.
- Keep OTP off only if those controls exist. Paystack also offers a transfer approval URL that your server answers with 200 (approve) or 400 (reject); if it doesn’t answer within a few seconds, the transfer is
blocked. - Keep payout runs to one process at a time so two runs can’t pick up the same
newpayout. - Send one small live payout per network you’ll use, to confirm the phone format, especially in Kenya.
- Reconcile daily against your Paystack balance history.
Paying out through the networks directly? See MTN MoMo Disbursements in Node.js and, in Kenya, M-Pesa B2C payouts. In Uganda, see Flutterwave mobile money payouts.
Comparing payout APIs? See mobile money payouts: what each one pays, who can sign up, and how each lets you recover from a lost response.
Frequently asked questions
How do I send money to a mobile money wallet with Paystack?
Create a transfer recipient once (POST /transferrecipient with type mobile_money, the phone number as account_number, and bank_code MTN, VOD or ATL in Ghana, or MPESA in Kenya). Then POST /transfer with source balance, the amount in subunits, the recipient_code, the currency and your own reference.
Can I use Paystack transfers without a registered business?
No. Paystack says only registered businesses can use the transfer feature. A starter business has to be upgraded first.
My Paystack transfer request timed out. Is it safe to send it again?
Yes, if you send it with the same reference. Paystack advises retrying with the same reference to avoid double crediting. Better still, look it up first with GET /transfer/verify/:reference and only resend if Paystack has no transfer under that reference.
Why is my transfer status "otp"?
Transfers need an OTP by default. Until you turn off "Confirm transfers before sending" in your dashboard preferences, each transfer waits for someone to enter an OTP, and it is abandoned after 30 minutes.
Is a "success" transfer final?
Not quite. Paystack says success doesn't equal instant credit to the customer, and a transfer can still be reversed: the amount comes back to your balance and you get a transfer.reversed webhook.