This guide sends money from your Flutterwave balance to MTN Mobile Money and Airtel Money wallets in Uganda with Flutterwave’s v3 Transfers API, from Node.js: refunds, seller payouts, salaries. You’ll build:
- a
flutterwave-transfers.jsclient: create a transfer and look one up, - a
payouts.jsmodule that never sends the same payout twice, even after a timeout or a crash, - a webhook that checks
verif-hashand settles payouts only from the looked-up transfer, - a test that runs every case without calling Flutterwave.
It uses Node’s built-in fetch (Node 18+). Every code block was run in a fresh project, and the test at the end passes. API facts come from Flutterwave’s public v3 documentation, checked in November 2026. To receive payments, see Flutterwave mobile money in Uganda.
How a payout works
- You save the payout with your own
reference, then callPOST /v3/transfers. - Flutterwave answers
"Transfer Queued Successfully"withdata.idandstatusNEW. That is not a payout yet. - The status moves through
PENDINGtoSUCCESSFULorFAILED. Flutterwave sends atransfer.completedwebhook either way; readdata.status. - You confirm with
GET /v3/transfers/{id}, the source of truth.
There’s one gap to design around. Flutterwave’s docs describe looking a transfer up by its id, which you only learn from the response. If the response is lost, you can’t check the transfer yourself. So a timed-out payout is never resent automatically: it waits for the webhook, which carries your reference and the id, or for a person to check the dashboard.
Before you start
- In your Flutterwave dashboard, switch to test mode, copy the test secret key, and fund the test balance you’ll pay out from.
- In the webhook settings, set your webhook URL and a long random secret hash, and turn on retries.
- Create a
.envfile (load it withnode --env-file=.env) and never commit it:
FLW_SECRET_KEY=your-test-secret-key
FLW_SECRET_HASH=the-same-secret-hash-as-your-dashboard
Install Express with npm install express, and set "type": "module" in package.json (npm pkg set type=module).
The transfers client
// flutterwave-transfers.js
const BASE = 'https://api.flutterwave.com/v3';
export class FlwError extends Error {
constructor(message, { status = 0, ambiguous = false, body = null } = {}) {
super(message);
this.status = status;
this.ambiguous = ambiguous; // true: the transfer may exist. Never send it again automatically.
this.body = body;
}
}
async function call(path, init = {}) {
let res;
try {
res = await fetch(`${BASE}${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.FLW_SECRET_KEY}`, 'Content-Type': 'application/json', ...init.headers },
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
throw new FlwError(`Flutterwave unreachable: ${err.message}`, { ambiguous: true });
}
const body = await res.json().catch(() => null);
if (res.status >= 500 || !body) throw new FlwError(`Flutterwave HTTP ${res.status}`, { status: res.status, ambiguous: true, body });
return { res, body };
}
/** Queues a mobile money transfer. Returns Flutterwave's data: id, reference, status ("NEW"). */
export async function createTransfer({ reference, amount, currency, phone, network, name, narration }) {
const { res, body } = await call('/transfers', {
method: 'POST',
body: JSON.stringify({
account_bank: network, // the operator code, e.g. "MTN" or "Airtel" for Uganda
account_number: phone, // the recipient's number with the country code: 256772123456
amount,
currency,
beneficiary_name: name,
narration: String(narration).slice(0, 100),
reference, // your own reference, saved before this call
}),
});
if (res.ok && body.status === 'success' && body.data?.id) return body.data;
throw new FlwError(`Transfer refused: ${body.message ?? res.status}`, { status: res.status, body });
}
/** The source of truth for a transfer you have the id of: { id, reference, status, amount, currency, complete_message }. */
export async function getTransfer(id) {
const { res, body } = await call(`/transfers/${encodeURIComponent(id)}`);
if (res.ok && body.status === 'success' && body.data) return body.data;
throw new FlwError(`Transfer lookup failed: ${res.status}`, { status: res.status, ambiguous: true, body });
}
The operator code. Flutterwave’s mobile money page lists Airtel, MPS and MTN as the options for Uganda (UGX). This guide sends MTN or Airtel in account_bank, chosen from the number’s prefix; confirm which code your account expects in test mode before going live. The African Phone Number Formatter shows the network for any Ugandan number, and account_number takes the country code: 256772123456.
Never pay twice
new ──send──▶ sending ──queued (id)──▶ pending ──lookup: SUCCESSFUL──▶ paid
│ └────lookup: FAILED──▶ failed
├──4xx (clear refusal)──▶ failed
└──timeout / 5xx──▶ unknown ──webhook brings the id──▶ pending / paid / failed
// payouts.js
import { randomUUID } from 'node:crypto';
import { createTransfer, FlwError, getTransfer } from './flutterwave-transfers.js';
const flw = { createTransfer, getTransfer };
const FINAL = ['paid', 'failed', 'review'];
/** Create the payout record. Its reference is fixed for life and never reused. */
export function newPayout({ amount, phone, network, name, narration, currency = 'UGX' }) {
if (!Number.isInteger(amount) || amount <= 0) throw new Error('Payout amounts are whole shillings');
if (!/^256\d{9}$/.test(phone)) throw new Error('Phone must look like 256772123456');
return {
reference: `payout-${randomUUID()}`, amount, currency, phone, network, name, narration,
status: 'new', transferId: null, reason: null,
};
}
/** Sends a payout once. save(payout) must persist the record. Only "new" is ever sent. */
export async function sendPayout(payout, { save, api = flw }) {
if (payout.status !== 'new') return payout; // sent, or maybe sent: never again
await save({ ...payout, status: 'sending' }); // saved BEFORE the network call
try {
const data = await api.createTransfer(payout);
return save({ ...payout, status: 'pending', transferId: data.id });
} catch (err) {
if (err instanceof FlwError && !err.ambiguous) {
return save({ ...payout, status: 'failed', reason: err.body?.message ?? `HTTP_${err.status}` });
}
// No answer: the transfer may exist, and we don't have its id. Wait for the webhook, never resend.
return save({ ...payout, status: 'unknown', reason: 'NO_RESPONSE' });
}
}
/** Applies a transfer fetched from Flutterwave 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 || transfer.currency !== payout.currency) {
return save({ ...payout, status: 'review', transferId: transfer.id, reason: `TRANSFER ${transfer.amount} ${transfer.currency}` });
}
const status = String(transfer.status ?? '').toUpperCase();
if (status === 'SUCCESSFUL') return save({ ...payout, status: 'paid', transferId: transfer.id, reason: null });
if (status === 'FAILED') return save({ ...payout, status: 'failed', transferId: transfer.id, reason: transfer.complete_message || 'FAILED' });
return save({ ...payout, status: 'pending', transferId: transfer.id }); // NEW or PENDING
}
/**
* Handles a transfer.completed webhook whose verif-hash you already checked.
* The body only tells you which transfer to look up; getTransfer decides.
*/
export async function handleWebhook(body, { find, save, api = flw }) {
const data = body?.data;
if (body?.event !== 'transfer.completed' || !data?.id || !data?.reference) return null;
const payout = await find(String(data.reference));
if (!payout) return null; // not ours
if (payout.transferId && String(payout.transferId) !== String(data.id)) return null; // doesn't match what Flutterwave gave us
return settle(payout, await api.getTransfer(data.id), { save });
}
/** Scheduled check for payouts still in flight. Payouts without a transfer id wait for the webhook or a person. */
export async function checkPayout(payout, { save, api = flw }) {
if (!['pending', 'unknown'].includes(payout.status) || !payout.transferId) return payout;
let transfer;
try {
transfer = await api.getTransfer(payout.transferId);
} catch {
return payout; // Flutterwave unreachable: try again on the next run
}
return settle(payout, transfer, { save });
}
The rules:
- Create the reference once, and save it before sending. It identifies the payout in the webhook.
- Never send from any state except
new. A payout that issending,pendingorunknownmay already be on its way. - Never resend automatically. An
unknownpayout waits for its webhook, or for a person to check the dashboard. If it really never happened, create a new payout with a new reference, on purpose. - Only the looked-up transfer counts. The webhook body only says which transfer to fetch; its reference, amount and currency must match the payout.
- Final means final.
paid,failedandreviewnever change.
Your save function should update the row by reference and refuse to change a payout that is paid, failed or review, for example with WHERE status NOT IN ('paid', 'failed', 'review').
The webhook
// server.js (Express)
import express from 'express';
import { timingSafeEqual } from 'node:crypto';
import { handleWebhook } from './payouts.js';
import { find, save } from './db.js'; // your database: find(reference) and save(payout)
const secretHash = process.env.FLW_SECRET_HASH ?? '';
if (secretHash.length < 32) throw new Error('Set FLW_SECRET_HASH to a random string of 32+ characters (and the same value in the dashboard)');
const app = express();
app.use(express.json());
function hashOk(given) {
const a = Buffer.from(String(given ?? ''));
const b = Buffer.from(secretHash);
return a.length === b.length && timingSafeEqual(a, b);
}
app.post('/webhooks/flutterwave', async (req, res) => {
if (!hashOk(req.get('verif-hash'))) return res.sendStatus(401);
try {
await handleWebhook(req.body, { find, save });
res.sendStatus(200);
} catch (err) {
console.error('Flutterwave webhook not processed', req.body?.data?.reference, err);
res.sendStatus(500); // Flutterwave retries if retries are on in your dashboard
}
});
app.listen(3000);
The webhook answers 200 only after the payout is saved; Flutterwave retries failed deliveries 3 times, 30 minutes apart, when retries are on. Run checkPayout from a job every few minutes for payouts that are pending. Alert yourself about payouts that stay unknown for more than an hour, and settle them from the dashboard.
Test it without calling Flutterwave
This test replaces the client with a fake Flutterwave. 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 leads to unknown and never a second transfer, that another payout’s transfer can’t settle it, that a wrong amount goes to review, and that final results never change.
// payouts.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FlwError } from './flutterwave-transfers.js';
import { checkPayout, handleWebhook, newPayout, sendPayout, settle } 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 && ['paid', 'failed', 'review'].includes(old.status)) return old; // final results never change
rows.set(p.reference, p);
return p;
},
};
}
function fakeFlutterwave() {
const transfers = new Map(); // id -> transfer, like Flutterwave's side
let nextId = 85623;
return {
transfers,
sent: [],
timeoutNext: false, // the next transfer is created but the response is lost
async createTransfer(p) {
this.sent.push(p.reference);
const t = { id: nextId++, reference: p.reference, amount: p.amount, currency: p.currency, status: 'NEW', complete_message: '' };
transfers.set(t.id, t);
if (this.timeoutNext) {
this.timeoutNext = false;
throw new FlwError('Flutterwave unreachable: timeout', { ambiguous: true });
}
return t;
},
async getTransfer(id) {
const t = transfers.get(Number(id));
if (!t) throw new FlwError('Transfer lookup failed: 404', { status: 404, ambiguous: true });
return t;
},
byRef(ref) {
return [...transfers.values()].find((t) => t.reference === ref);
},
};
}
const payout = () => newPayout({ amount: 50000, phone: '256772123456', network: 'MTN', name: 'Jane Namuli', narration: 'Seller payout 77' });
const event = (t, extra = {}) => ({ event: 'transfer.completed', data: { id: t.id, reference: t.reference, status: 'SUCCESSFUL', ...extra } });
test('a payout is saved, sent once, and paid from the looked-up transfer', async () => {
const db = makeDb();
const api = fakeFlutterwave();
const real = api.createTransfer.bind(api);
api.createTransfer = async (p) => {
assert.equal(db.rows.get(p.reference)?.status, 'sending'); // saved BEFORE the money moves
return real(p);
};
let p = await sendPayout(payout(), { ...db, api });
assert.deepEqual([p.status, p.transferId], ['pending', 85623]);
assert.equal((await sendPayout(p, { ...db, api })).status, 'pending'); // calling again sends nothing
assert.equal(api.sent.length, 1);
const t = api.byRef(p.reference);
t.status = 'SUCCESSFUL';
p = await handleWebhook(event(t), { ...db, api });
assert.equal(p.status, 'paid');
});
test('the webhook body is never trusted: only the looked-up transfer counts', async () => {
const db = makeDb();
const api = fakeFlutterwave();
const p = await sendPayout(payout(), { ...db, api });
const t = api.byRef(p.reference); // still NEW at Flutterwave
await handleWebhook(event(t, { status: 'SUCCESSFUL' }), { ...db, api });
assert.equal(db.rows.get(p.reference).status, 'pending');
assert.equal(await handleWebhook(event({ id: 999, reference: p.reference }), { ...db, api }), null); // another transfer id
assert.equal(await handleWebhook({ event: 'charge.completed', data: { id: t.id, reference: p.reference } }, { ...db, api }), null);
assert.equal(await handleWebhook(event({ id: t.id, reference: 'not-ours' }), { ...db, api }), null);
});
test('no response means unknown, never a second transfer; the webhook settles it', async () => {
const db = makeDb();
const api = fakeFlutterwave();
api.timeoutNext = true; // Flutterwave created it, but we never got the id
let p = await sendPayout(payout(), { ...db, api });
assert.deepEqual([p.status, p.transferId], ['unknown', null]);
await sendPayout(p, { ...db, api });
assert.equal((await checkPayout(p, { ...db, api })).status, 'unknown'); // no id: nothing to look up, nothing resent
assert.equal(api.sent.length, 1);
const t = api.byRef(p.reference);
t.status = 'SUCCESSFUL';
p = await handleWebhook(event(t), { ...db, api }); // the webhook carries the id
assert.deepEqual([p.status, p.transferId], ['paid', t.id]);
});
test('another payout\'s transfer cannot settle an unknown payout', async () => {
const db = makeDb();
const api = fakeFlutterwave();
const other = await sendPayout(payout(), { ...db, api });
api.timeoutNext = true;
const unknown = await sendPayout(payout(), { ...db, api });
const otherTransfer = api.byRef(other.reference);
otherTransfer.status = 'SUCCESSFUL';
// A body naming the unknown payout's reference but the other payout's transfer id:
await handleWebhook(event({ id: otherTransfer.id, reference: unknown.reference }), { ...db, api });
assert.equal(db.rows.get(unknown.reference).status, 'unknown');
});
test('settle itself never changes a final payout', async () => {
const save = async (p) => p; // even with a database that doesn't guard
for (const status of ['paid', 'failed', 'review']) {
const p = { ...payout(), status, transferId: 1 };
assert.equal((await settle(p, { id: 1, reference: p.reference, amount: p.amount, currency: 'UGX', status: 'FAILED' }, { save })).status, status);
}
});
test('a clear refusal fails at once', async () => {
const db = makeDb();
const api = { ...fakeFlutterwave(), async createTransfer() { throw new FlwError('Transfer refused: Insufficient balance', { status: 400, body: { status: 'error', message: 'Insufficient balance' } }); } };
const p = await sendPayout(payout(), { ...db, api });
assert.deepEqual([p.status, p.reason], ['failed', 'Insufficient balance']);
});
test('the scheduled check settles pending payouts; failed keeps the reason and is final', async () => {
const db = makeDb();
const api = fakeFlutterwave();
let p = await sendPayout(payout(), { ...db, api });
assert.equal((await checkPayout(p, { ...db, api })).status, 'pending'); // still NEW
const t = api.byRef(p.reference);
t.status = 'FAILED';
t.complete_message = 'Recipient wallet not found';
p = await checkPayout(p, { ...db, api });
assert.deepEqual([p.status, p.reason], ['failed', 'Recipient wallet not found']);
t.status = 'SUCCESSFUL'; // even if a later lookup disagreed...
await handleWebhook(event(t), { ...db, api });
assert.equal(db.rows.get(p.reference).status, 'failed'); // ...the saved result stays
});
test('a transfer for another amount or currency goes to review', async () => {
const db = makeDb();
const api = fakeFlutterwave();
const p = await sendPayout(payout(), { ...db, api });
const t = api.byRef(p.reference);
Object.assign(t, { status: 'SUCCESSFUL', amount: 5000 });
assert.equal((await handleWebhook(event(t), { ...db, api })).status, 'review');
});
test('Flutterwave unreachable leaves the payout alone', async () => {
const db = makeDb();
const api = fakeFlutterwave();
const p = await sendPayout(payout(), { ...db, api });
const down = { ...api, async getTransfer() { throw new FlwError('Flutterwave unreachable', { ambiguous: true }); } };
assert.equal((await checkPayout(p, { ...db, api: down })).status, 'pending');
});
test('only whole shillings and full international numbers are accepted', () => {
assert.throws(() => newPayout({ amount: 500.5, phone: '256772123456', network: 'MTN', name: 'x', narration: 'x' }), /whole shillings/);
assert.throws(() => newPayout({ amount: 500, phone: '0772123456', network: 'MTN', name: 'x', narration: 'x' }), /256772123456/);
assert.notEqual(payout().reference, payout().reference);
});
Run it with node --test.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Transfer refused with a balance message |
Your Flutterwave balance is too low | Fund the balance before a payout run |
Payout failed with a wallet message |
The number isn’t an active wallet on that network, or the wrong operator code | Check the number and account_bank |
Webhooks get 401 |
FLW_SECRET_HASH differs from the dashboard |
Use exactly the same value in both places |
Payout stays unknown |
No response, and no webhook yet | Wait for the webhook; check the dashboard. Never resend with a new reference |
review |
The transfer’s amount or currency differs from the payout | Look at it in the dashboard |
Going to production
- Complete Flutterwave’s business verification, switch to your live secret key, and set a new live secret hash.
- 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 payout runs to one process at a time so two runs can’t pick up the same
newpayout. - Send one small live payout to an MTN number and one to an Airtel number, to confirm the operator codes.
- Reconcile daily against your Flutterwave balance history.
Paying out through the networks directly? See MTN MoMo Disbursements in Node.js and, in Kenya, M-Pesa B2C 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 an MTN or Airtel wallet with Flutterwave?
POST /v3/transfers with account_bank set to the mobile money operator code, account_number set to the number with its country code (256772123456), amount, currency UGX, beneficiary_name and your own reference. Flutterwave answers "Transfer Queued Successfully" with status NEW; the result comes later.
Is "Transfer Queued Successfully" a completed payout?
No. It means Flutterwave accepted the transfer, with status NEW. It is complete only when GET /v3/transfers/{id} says SUCCESSFUL.
My transfer request timed out. Should I send it again?
No. Flutterwave may have created it, and you don't have its id to check. Mark the payout unknown and wait for the transfer.completed webhook, which carries your reference and the transfer id, or check the dashboard before deciding anything.
Is the Flutterwave transfer webhook only sent for successful transfers?
No. Flutterwave sends transfer.completed both when a transfer succeeds and when it fails. Read data.status, then confirm it with GET /v3/transfers/{id}.