This guide sends money to MTN MoMo wallets from Node.js with the Disbursements API: refunds, payouts to sellers or drivers, salaries and cash back. By the end you’ll have:
- a
momo-disbursements.jsclient with token caching, transfers, transfer status and balance, - a
payouts.jsmodule that never sends the same payout twice, even after a timeout or a crash, - a check job that settles payouts and safely retries ones MTN never received,
- a test that runs every case without calling MTN.
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. If you haven’t used MoMo before, start with the Collections guide: Disbursements works the same way, with the differences below.
How Disbursements differs from Collections
| Collections (receive) | Disbursements (send) | |
|---|---|---|
| Subscription key | Collections key | A separate Disbursements key |
| API user and key | Created with the Collections key | Created with the Disbursements key |
| Token endpoint | /collection/token/ |
/disbursement/token/ |
| Send | POST /collection/v1_0/requesttopay |
POST /disbursement/v1_0/transfer |
| Status | GET …/requesttopay/{X-Reference-Id} |
GET /disbursement/v1_0/transfer/{X-Reference-Id} |
| Who’s in the body | payer |
payee |
| Customer approval | PIN prompt on their phone | None: money leaves your account at once |
That last row is the one that matters. With Collections, a duplicate request just sends a second prompt that the customer can ignore. With Disbursements, a duplicate request sends the money twice.
Before you start
- On momodeveloper.mtn.com, subscribe to the Disbursements product and copy its primary key.
- Create a sandbox API user and key with that Disbursements key. Steps 1 and 2 of the MoMo Request Builder generate the cURL commands; switch it to Disbursements first.
- Put the credentials in a
.envfile (load it withnode --env-file=.env) and never commit it:
MOMO_BASE_URL=https://sandbox.momodeveloper.mtn.com
MOMO_TARGET_ENV=sandbox
MOMO_CURRENCY=EUR
MOMO_DISBURSEMENT_SUBSCRIPTION_KEY=your-disbursements-subscription-key
MOMO_DISBURSEMENT_API_USER=your-api-user-uuid
MOMO_DISBURSEMENT_API_KEY=your-api-key
The sandbox only accepts EUR.
The Disbursements client
The client is small: a token, a transfer, a status lookup and a balance check. It throws a MomoError that says whether the failure is ambiguous, meaning the transfer may or may not have been created. Timeouts and 5xx errors are ambiguous; a 400 is not.
// momo-disbursements.js
const env = process.env;
export class MomoError extends Error {
constructor(message, { status = 0, ambiguous = false, body = null } = {}) {
super(message);
this.status = status;
this.ambiguous = ambiguous; // true: the transfer may exist. Check its status; never resend with a new reference.
this.body = body;
}
}
let cached = { token: null, expiresAt: 0 };
async function call(path, init = {}) {
try {
return await fetch(`${env.MOMO_BASE_URL}${path}`, { ...init, signal: AbortSignal.timeout(30_000) });
} catch (err) {
throw new MomoError(`MoMo unreachable: ${err.message}`, { ambiguous: true });
}
}
export async function getToken() {
if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token;
const basic = Buffer.from(`${env.MOMO_DISBURSEMENT_API_USER}:${env.MOMO_DISBURSEMENT_API_KEY}`).toString('base64');
const res = await call('/disbursement/token/', {
method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Ocp-Apim-Subscription-Key': env.MOMO_DISBURSEMENT_SUBSCRIPTION_KEY, 'Content-Length': '0' },
});
if (!res.ok) throw new MomoError(`Disbursement token failed: ${res.status}`, { status: res.status });
const { access_token, expires_in } = await res.json();
cached = { token: access_token, expiresAt: Date.now() + Number(expires_in ?? 3600) * 1000 };
return access_token;
}
async function headers(extra = {}) {
return {
Authorization: `Bearer ${await getToken()}`,
'Ocp-Apim-Subscription-Key': env.MOMO_DISBURSEMENT_SUBSCRIPTION_KEY,
'X-Target-Environment': env.MOMO_TARGET_ENV,
...extra,
};
}
/** Sends money to a MoMo wallet. referenceId is a UUID v4 you saved BEFORE calling this. */
export async function transfer({ referenceId, amount, phone, externalId, note }) {
const res = await call('/disbursement/v1_0/transfer', {
method: 'POST',
headers: await headers({ 'X-Reference-Id': referenceId, 'Content-Type': 'application/json' }),
body: JSON.stringify({
amount: String(amount), // a string, no trailing ".00"
currency: env.MOMO_CURRENCY,
externalId: String(externalId),
payee: { partyIdType: 'MSISDN', partyId: phone },
payerMessage: String(note).slice(0, 160),
payeeNote: String(note).slice(0, 160),
}),
});
if (res.status === 202) return; // accepted: NOT paid yet, check the status
if (res.status === 409) return; // this X-Reference-Id was already received: the first request stands
const body = await res.json().catch(() => null);
throw new MomoError(`Transfer failed: ${res.status} ${body?.code ?? ''}`.trim(), { status: res.status, ambiguous: res.status >= 500, body });
}
/** The source of truth: { status: 'SUCCESSFUL' | 'FAILED' | 'PENDING', ... }, or null if MTN never received it. */
export async function getTransfer(referenceId) {
const res = await call(`/disbursement/v1_0/transfer/${referenceId}`, { headers: await headers() });
if (res.status === 404) return null;
if (!res.ok) throw new MomoError(`Transfer status failed: ${res.status}`, { status: res.status, ambiguous: true });
return res.json();
}
/** { availableBalance: "1000", currency: "EUR" } for your disbursement account. */
export async function getBalance() {
const res = await call('/disbursement/v1_0/account/balance', { headers: await headers() });
if (!res.ok) throw new MomoError(`Balance failed: ${res.status}`, { status: res.status });
return res.json();
}
Phone numbers go in international format without the +, for example 256772123456. The Collections guide has a normalisePhone helper, and the African Phone Number Formatter shows the right format for each country.
Never pay twice
A payout goes through these states:
new ──send──▶ sending ──202──▶ pending ──status──▶ paid
│ └─────▶ failed
├──400 (clear refusal)──▶ failed
└──timeout / 5xx──▶ unknown ──status 404──▶ new (resend, same reference)
└──status found──▶ pending / paid / failed
Three rules keep it safe:
- Create the
X-Reference-Idonce, with the payout, and save it before sending. It is the transfer’s identity at MTN. A retry always reuses it. - Never send from any state except
new. A payout that issending,pendingorunknownmay already be on its way. - After a timeout, ask before resending. If MTN has the transfer, wait for its result. Only a
404 Not Foundproves it was never created, and even then you resend with the same reference. If two requests ever do arrive, MTN rejects the second with409 Conflict.
// payouts.js
import { randomUUID } from 'node:crypto';
import { getBalance, getTransfer, MomoError, transfer } from './momo-disbursements.js';
const momo = { transfer, getTransfer, getBalance };
/** Create the payout record. The reference is fixed for its whole life. */
export function newPayout({ amount, phone, externalId, note }) {
return { referenceId: randomUUID(), amount: String(amount), phone, externalId, note, status: 'new', reason: null, financialTransactionId: null };
}
/**
* Sends a payout once. save(payout) must persist the record (your database).
* Only a payout in status "new" is ever sent.
*/
export async function sendPayout(payout, { save, api = momo }) {
if (payout.status !== 'new') return payout; // already sent, or maybe sent: never again
await save({ ...payout, status: 'sending' }); // saved BEFORE the network call
try {
await api.transfer(payout);
return save({ ...payout, status: 'pending' });
} catch (err) {
if (err instanceof MomoError && !err.ambiguous) {
return save({ ...payout, status: 'failed', reason: err.body?.code ?? `HTTP_${err.status}` });
}
return save({ ...payout, status: 'unknown' }); // may have gone through: checkPayout decides
}
}
/** Settles a payout from MTN's answer. Safe to run as often as you like. */
export async function checkPayout(payout, { save, api = momo }) {
if (!['sending', 'pending', 'unknown'].includes(payout.status)) return payout;
let result;
try {
result = await api.getTransfer(payout.referenceId);
} catch {
return payout; // MTN unreachable: try again on the next run
}
if (result === null) {
// MTN never received it. Only now is it safe to send again, with the SAME reference.
return payout.status === 'pending' ? payout : save({ ...payout, status: 'new' });
}
const status = String(result.status ?? '').toUpperCase();
if (status === 'SUCCESSFUL') {
return save({ ...payout, status: 'paid', financialTransactionId: result.financialTransactionId ?? null });
}
if (status === 'FAILED') {
const reason = typeof result.reason === 'object' ? result.reason?.code : result.reason;
return save({ ...payout, status: 'failed', reason: reason ?? 'FAILED' });
}
return payout.status === 'pending' ? payout : save({ ...payout, status: 'pending' }); // still PENDING at MTN
}
/** Sends a batch one by one, and stops before the disbursement account runs dry. */
export async function runPayouts(payouts, { save, api = momo }) {
const { availableBalance } = await api.getBalance();
let left = Number(availableBalance);
const results = [];
for (const payout of payouts) {
if (payout.status !== 'new') {
results.push(payout);
continue;
}
if (Number(payout.amount) > left) {
results.push(payout); // left as "new" for the next run, after you top up
continue;
}
const sent = await sendPayout(payout, { save, api });
if (sent.status !== 'failed') left -= Number(payout.amount);
results.push(sent);
}
return results;
}
Run checkPayout from a job every minute or two for every payout in sending, pending or unknown. Any payout that check turns back into new is sent again by your next runPayouts run, with its original reference.
Your save function should update the row by referenceId, and refuse to move a payout out of paid or failed, for example with WHERE status NOT IN ('paid', 'failed'). Then a late or repeated check can never undo a final result.
Test it without calling MTN
This test replaces the client with a fake MTN that remembers every transfer by reference. It checks that a payout is saved before it’s sent and sent only once, that a timeout leads to a status check instead of a second transfer, that a transfer MTN never received is resent with the same reference, that a clear refusal fails immediately, and that a batch stops at the balance.
// payouts.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MomoError } from './momo-disbursements.js';
import { checkPayout, newPayout, runPayouts, sendPayout } from './payouts.js';
function fakeMtn({ balance = '1000' } = {}) {
const transfers = new Map(); // referenceId -> transfer, like MTN's side
const sent = [];
return {
transfers,
sent,
timeoutNext: false, // the next transfer is created but the response is lost
dropNext: false, // the next transfer is lost before MTN sees it
async transfer(p) {
sent.push(p.referenceId);
if (this.dropNext) {
this.dropNext = false;
throw new MomoError('MoMo unreachable: timeout', { ambiguous: true });
}
if (transfers.has(p.referenceId)) return; // 409: MTN already has it
transfers.set(p.referenceId, { status: 'PENDING', amount: p.amount });
if (this.timeoutNext) {
this.timeoutNext = false;
throw new MomoError('MoMo unreachable: timeout', { ambiguous: true });
}
},
async getTransfer(ref) {
return transfers.get(ref) ?? null; // null = 404
},
async getBalance() {
return { availableBalance: balance, currency: 'EUR' };
},
};
}
function makeDb() {
const rows = new Map();
return {
rows,
save: async (p) => {
const old = rows.get(p.referenceId);
if (old && ['paid', 'failed'].includes(old.status)) return old; // final results never change
rows.set(p.referenceId, p);
return p;
},
};
}
test('a payout is saved, then sent once, and settles from the status', async () => {
const api = fakeMtn();
const db = makeDb();
const realTransfer = api.transfer.bind(api);
api.transfer = async (payout) => {
assert.equal(db.rows.get(payout.referenceId)?.status, 'sending'); // saved BEFORE the money moves
return realTransfer(payout);
};
const created = newPayout({ amount: 500, phone: '256772123456', externalId: 'refund-1', note: 'Refund 1' });
assert.match(created.referenceId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); // a UUID v4, fixed up front
let p = await sendPayout(created, { ...db, api });
assert.equal(p.status, 'pending');
assert.equal((await sendPayout(p, { ...db, api })).status, 'pending'); // calling again sends nothing
assert.equal(api.sent.length, 1);
api.transfers.get(p.referenceId).status = 'SUCCESSFUL';
api.transfers.get(p.referenceId).financialTransactionId = '987654';
p = await checkPayout(p, { ...db, api });
assert.deepEqual([p.status, p.financialTransactionId], ['paid', '987654']);
});
test('after a timeout it checks instead of sending again', async () => {
const api = fakeMtn();
const db = makeDb();
api.timeoutNext = true; // MTN created it, but we never got the 202
let p = await sendPayout(newPayout({ amount: 500, phone: '256772123456', externalId: 'payout-2', note: 'Payout' }), { ...db, api });
assert.equal(p.status, 'unknown');
p = await checkPayout(p, { ...db, api });
assert.equal(p.status, 'pending'); // MTN has it: wait, don't resend
assert.equal(api.sent.length, 1);
});
test('a transfer MTN never received is resent with the same reference', async () => {
const api = fakeMtn();
const db = makeDb();
api.dropNext = true;
let p = await sendPayout(newPayout({ amount: 500, phone: '256772123456', externalId: 'payout-3', note: 'Payout' }), { ...db, api });
const firstReference = p.referenceId;
p = await checkPayout(p, { ...db, api }); // 404: never created
assert.equal(p.status, 'new');
p = await sendPayout(p, { ...db, api });
assert.equal(p.status, 'pending');
assert.deepEqual(api.sent, [firstReference, firstReference]);
assert.equal(api.transfers.size, 1);
});
test('a clear refusal fails at once and is never retried', async () => {
const db = makeDb();
const api = { ...fakeMtn(), async transfer() { throw new MomoError('Transfer failed: 400', { status: 400, body: { code: 'PAYEE_NOT_FOUND' } }); } };
const p = await sendPayout(newPayout({ amount: 500, phone: '256700000000', externalId: 'payout-4', note: 'Payout' }), { ...db, api });
assert.deepEqual([p.status, p.reason], ['failed', 'PAYEE_NOT_FOUND']);
assert.equal((await checkPayout(p, { ...db, api })).status, 'failed');
});
test('a final result is never undone', async () => {
const api = fakeMtn();
const db = makeDb();
let p = await sendPayout(newPayout({ amount: 500, phone: '256772123456', externalId: 'payout-5', note: 'Payout' }), { ...db, api });
api.transfers.get(p.referenceId).status = 'FAILED';
api.transfers.get(p.referenceId).reason = { code: 'NOT_ENOUGH_FUNDS' };
p = await checkPayout(p, { ...db, api });
assert.deepEqual([p.status, p.reason], ['failed', 'NOT_ENOUGH_FUNDS']);
api.transfers.delete(p.referenceId); // even if a later lookup returned 404...
await checkPayout({ ...p, status: 'pending' }, { ...db, api });
assert.equal(db.rows.get(p.referenceId).status, 'failed'); // ...the saved result stays
});
test('a batch stops at the available balance', async () => {
const api = fakeMtn({ balance: '800' });
const db = makeDb();
const batch = [500, 500, 200].map((amount, i) => newPayout({ amount, phone: '256772123456', externalId: `b-${i}`, note: 'Batch' }));
const results = await runPayouts(batch, { ...db, api });
assert.deepEqual(results.map((r) => r.status), ['pending', 'new', 'pending']); // 500 + 200 fit in 800
assert.equal(api.sent.length, 2);
});
Run it with node --test. The project needs "type": "module" in package.json (npm pkg set type=module).
Test against the sandbox
With the tests passing, send a real sandbox transfer of 1 EUR to a test number and check it with getTransfer. MTN’s sandbox test numbers simulate outcomes for transfers too: 46733123450 fails, 46733123451 is rejected, 46733123452 times out, 46733123453 stays pending, and numbers not on MTN’s list succeed. The full list is on the Request Builder page. Use 46733123453 to watch your check job keep a payout pending without resending it.
Common errors and fixes
| Error | Likely cause | Fix |
|---|---|---|
401 on the token or transfer |
Collection keys or token used for Disbursements | Use the Disbursements subscription key and an API user created with it |
400 with a reason code |
A field is invalid: currency, amount format or phone number | In the sandbox use EUR, send the amount as a string, and the phone without + |
409 Conflict |
The same X-Reference-Id was sent twice |
Expected on a retry. The first transfer stands; check its status |
FAILED with NOT_ENOUGH_FUNDS |
For a transfer this most likely means your disbursement account is short | Check getBalance() and top up before the next run |
FAILED with PAYEE_NOT_FOUND or PAYEE_NOT_ALLOWED_TO_RECEIVE |
The receiving number has no active MoMo wallet | Ask the receiver to confirm their MoMo number |
A payout stays unknown |
MTN wasn’t reachable when you checked | Leave it. The next check settles it; never resend with a new reference |
Every MoMo failure reason is explained in the MTN MoMo error code reference.
Going to production
- Disbursements needs its own production approval from MTN. Use the production Disbursements subscription key, API user and key from the MTN MoMo Partner Portal.
- Set
MOMO_BASE_URLto the production host,MOMO_TARGET_ENVto your country (for examplemtnuganda) andMOMO_CURRENCYto the local currency (for exampleUGX). - Store payouts in your database with
referenceIdas a unique key, and makesaverefuse to change apaidorfailedpayout. - Run the check job every minute or two, and keep payout runs to one process at a time so two runs can’t pick up the same
newpayout. - Protect the code path 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.
- Reconcile daily against your MoMo statement, especially payouts that went through
unknown.
Paying M-Pesa wallets in Kenya? See M-Pesa B2C payouts in Node.js.
Frequently asked questions
Can I use my Collections keys for MoMo Disbursements?
No. Disbursements is a separate MoMo product with its own subscription key, its own API user and key, and its own token endpoint (/disbursement/token/). Collection tokens are rejected by the transfer endpoint.
Does the receiver have to approve a MoMo disbursement?
No. Unlike Request to Pay, a transfer has no PIN prompt: the money leaves your disbursement account straight away. That is why you must never send the same payout twice, and why this guide saves the X-Reference-Id before sending.
My transfer request timed out. Should I send it again?
Not with a new reference. Check GET /disbursement/v1_0/transfer/{X-Reference-Id} first. If MTN has it, wait for its result. If MTN answers 404 Not Found, the transfer was never created, so you can send it again with the same X-Reference-Id.
How do I check my MoMo disbursement balance?
Call GET /disbursement/v1_0/account/balance with a Disbursement token. It returns availableBalance and currency. Check it before a payout run so you stop early instead of failing halfway through.
Is a 202 Accepted from the transfer endpoint a completed payout?
No. 202 means MTN accepted the request. The payout is complete only when the transfer status is SUCCESSFUL.