This guide sends money to MTN MoMo wallets from a Next.js App Router app with the Disbursements API: refunds, payouts to sellers or drivers, and cash back. You’ll build:
lib/momo-disbursements.ts: token caching, transfers, transfer status and balance,lib/payouts.ts: payouts that are never sent twice, even after a timeout, a crash or two clicks at once,- an admin route that creates and sends a payout, protected by a token and limits,
- a check route that settles payouts and safely resends ones MTN never received,
- tests that run every case without calling MTN.
Every code block was run in a fresh Next.js 16 app (create-next-app with TypeScript and the App Router): the tests pass and next build succeeds. The same design in other stacks: Node.js, Laravel and Django. Receiving payments too? See MTN MoMo Collections in Next.js.
Why payouts need more care than payments
A Request to Pay sends a prompt the customer can ignore. A transfer has no prompt: the money leaves your disbursement account straight away. A duplicate request sends the money twice. So:
- 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. - Claim the payout before sending. Move it from
newtosendingwith a conditional update. Only the request that wins the claim calls MTN. - After a timeout, ask before resending. Only a
404 Not Foundproves MTN never created the transfer, and even then you resend with the same reference. MTN rejects a second request with that reference with409 Conflict.
new ──claim──▶ sending ──202──▶ pending ──status──▶ paid
│ └────▶ failed
├──400 (clear refusal)──▶ failed
└──timeout / 5xx──▶ unknown ──status 404──▶ new (resend, same reference)
└──status found──▶ pending / paid / failed
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 settings in .env.local (Next.js keeps it out of git by default):
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
PAYOUT_ADMIN_TOKEN=a-long-random-string-for-your-admin-tools
PAYOUT_MAX_AMOUNT=500000
PAYOUT_DAILY_LIMIT=2000000
CRON_SECRET=another-long-random-string
The Disbursements client
// lib/momo-disbursements.ts
const env = process.env;
const base = () => (env.MOMO_BASE_URL ?? 'https://sandbox.momodeveloper.mtn.com').replace(/\/+$/, '');
export class MomoError extends Error {
/** ambiguous: the transfer may exist. Check its status; never resend with a new reference. */
constructor(message: string, readonly ambiguous: boolean, readonly code = '') {
super(message);
}
}
let token: { value: string; expiresAt: number } | null = null;
async function call(path: string, init: RequestInit): Promise<Response> {
try {
return await fetch(`${base()}${path}`, { ...init, cache: 'no-store', signal: AbortSignal.timeout(30_000) });
} catch (err) {
throw new MomoError(`MoMo unreachable: ${(err as Error).message}`, true);
}
}
async function getToken(): Promise<string> {
if (token && Date.now() < token.expiresAt - 60_000) return token.value;
const basic = Buffer.from(`${env.MOMO_DISBURSEMENT_API_USER}:${env.MOMO_DISBURSEMENT_API_KEY}`).toString('base64');
const res = await call('/disbursement/token/', {
method: 'POST', // no body: MoMo wants Content-Length: 0
headers: { Authorization: `Basic ${basic}`, 'Ocp-Apim-Subscription-Key': env.MOMO_DISBURSEMENT_SUBSCRIPTION_KEY ?? '' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.access_token) throw new MomoError(`Disbursement token failed: HTTP ${res.status}`, false);
token = { value: data.access_token, expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000 };
return token.value;
}
async function headers(extra: Record<string, string> = {}): Promise<Record<string, string>> {
return {
Authorization: `Bearer ${await getToken()}`,
'Ocp-Apim-Subscription-Key': env.MOMO_DISBURSEMENT_SUBSCRIPTION_KEY ?? '',
'X-Target-Environment': env.MOMO_TARGET_ENV ?? 'sandbox',
...extra,
};
}
export interface TransferRequest {
referenceId: string; // a UUID v4 saved BEFORE calling transfer()
amount: string; // a string, no trailing ".00"
currency: string;
phone: string; // international, no "+": 256772123456
externalId: string;
note: string;
}
/** Sends money. 202 means accepted, NOT paid: check the status. */
export async function transfer(t: TransferRequest): Promise<void> {
const res = await call('/disbursement/v1_0/transfer', {
method: 'POST',
headers: await headers({ 'X-Reference-Id': t.referenceId, 'Content-Type': 'application/json' }),
body: JSON.stringify({
amount: t.amount,
currency: t.currency,
externalId: t.externalId,
payee: { partyIdType: 'MSISDN', partyId: t.phone },
payerMessage: t.note.slice(0, 160),
payeeNote: t.note.slice(0, 160),
}),
});
if (res.status === 202 || res.status === 409) return; // 409: this reference was already received; the first request stands
const body = await res.json().catch(() => ({}));
throw new MomoError(`Transfer failed: HTTP ${res.status} ${body?.code ?? ''}`.trim(), res.status >= 500, String(body?.code ?? `HTTP_${res.status}`));
}
export interface TransferStatus {
status?: string; // SUCCESSFUL, FAILED, PENDING
financialTransactionId?: string;
reason?: unknown;
}
/** The source of truth, or null if MTN never received the transfer. */
export async function getTransfer(referenceId: string): Promise<TransferStatus | null> {
const res = await call(`/disbursement/v1_0/transfer/${encodeURIComponent(referenceId)}`, { headers: await headers() });
if (res.status === 404) return null;
if (!res.ok) throw new MomoError(`Transfer status failed: HTTP ${res.status}`, true);
return res.json();
}
/** { availableBalance: "1000", currency: "EUR" } for your disbursement account. */
export async function getBalance(): Promise<{ availableBalance: string; currency: string }> {
const res = await call('/disbursement/v1_0/account/balance', { headers: await headers() });
if (!res.ok) throw new MomoError(`Balance failed: HTTP ${res.status}`, true);
return res.json();
}
Phone numbers go in international format without the +, for example 256772123456. The African Phone Number Formatter shows the right format for each country.
Never pay twice
Your database goes here. This file keeps payouts in a Map so the guide runs on its own. When you replace the store functions, keep the two rules they enforce:
claimmoves a payout from one status to another only if it is still in the first one, in one statement:UPDATE payouts SET status = 'sending' WHERE reference_id = $1 AND status = 'new', then check that one row changed.updaterefuses to change apaidorfailedpayout:... WHERE status NOT IN ('paid', 'failed').
// lib/payouts.ts
import { randomUUID } from 'node:crypto';
import { getBalance, getTransfer, MomoError, transfer } from './momo-disbursements';
export type Status = 'new' | 'sending' | 'pending' | 'unknown' | 'paid' | 'failed';
export interface Payout {
referenceId: string; // fixed for the payout's whole life
amount: string;
currency: string;
phone: string;
externalId: string;
note: string;
createdBy: string;
status: Status;
reason?: string;
financialTransactionId?: string;
createdAt: number;
}
// ── Replace with your database ──────────────────────────────────────────────
const rows = new Map<string, Payout>();
async function insert(p: Payout): Promise<void> {
rows.set(p.referenceId, { ...p });
}
export async function findPayout(referenceId: string): Promise<Payout | null> {
const p = rows.get(referenceId);
return p ? { ...p } : null;
}
export async function payoutsIn(statuses: Status[]): Promise<Payout[]> {
return [...rows.values()].filter((p) => statuses.includes(p.status)).map((p) => ({ ...p }));
}
async function sentToday(currency: string): Promise<number> {
const midnight = new Date().setUTCHours(0, 0, 0, 0);
return [...rows.values()].filter((p) => p.currency === currency && p.status !== 'failed' && p.createdAt >= midnight).reduce((sum, p) => sum + Number(p.amount), 0);
}
/** Moves a payout from one status to another, only if it is still in the first. True if this call moved it. */
async function claim(referenceId: string, from: Status, to: Status): Promise<boolean> {
const p = rows.get(referenceId);
if (!p || p.status !== from) return false;
rows.set(referenceId, { ...p, status: to });
return true;
}
/** Changes a payout unless it is already paid or failed. */
async function update(referenceId: string, fields: Partial<Payout>): Promise<void> {
const p = rows.get(referenceId);
if (p && p.status !== 'paid' && p.status !== 'failed') rows.set(referenceId, { ...p, ...fields });
}
// ────────────────────────────────────────────────────────────────────────────
/** Checks the amount and limits, then saves the payout as "new". Throws RangeError when it isn't allowed. */
export async function createPayout(input: { amount: unknown; phone: unknown; externalId: unknown; note?: unknown; createdBy: string }): Promise<Payout> {
const amount = String(input.amount ?? '').trim();
if (!/^\d+(\.\d{1,2})?$/.test(amount) || Number(amount) <= 0) throw new RangeError('Amount must be a positive number like 25000 or 12.5');
const phone = String(input.phone ?? '').replace(/\D/g, '');
if (!/^\d{10,15}$/.test(phone)) throw new RangeError('Phone must be international digits without "+", e.g. 256772123456');
const currency = process.env.MOMO_CURRENCY ?? 'EUR';
const max = Number(process.env.PAYOUT_MAX_AMOUNT ?? 0);
const daily = Number(process.env.PAYOUT_DAILY_LIMIT ?? 0);
if (max > 0 && Number(amount) > max) throw new RangeError(`More than the ${max} ${currency} limit per payout`);
if (daily > 0 && (await sentToday(currency)) + Number(amount) > daily) throw new RangeError(`More than the ${daily} ${currency} daily limit`);
const payout: Payout = {
referenceId: randomUUID(), amount: amount.includes('.') ? amount.replace(/\.?0+$/, '') : amount, currency, phone,
externalId: String(input.externalId ?? '').slice(0, 64), note: String(input.note ?? 'Payout').slice(0, 160),
createdBy: input.createdBy, status: 'new', createdAt: Date.now(),
};
await insert(payout); // saved, with its reference, BEFORE anything is sent
return payout;
}
/** Sends a payout once. Only the caller that claims it from "new" calls MTN. */
export async function sendPayout(referenceId: string): Promise<Payout | null> {
if (!(await claim(referenceId, 'new', 'sending'))) return findPayout(referenceId); // already sent, or maybe sent: never again
const payout = (await findPayout(referenceId))!;
const { available } = await balanceFor(payout);
if (available !== null && available < Number(payout.amount)) {
await claim(referenceId, 'sending', 'new'); // not enough money: leave it for the next run, after you top up
return findPayout(referenceId);
}
try {
await transfer(payout);
await claim(referenceId, 'sending', 'pending');
} catch (err) {
if (!(err instanceof MomoError)) throw err;
if (err.ambiguous) await claim(referenceId, 'sending', 'unknown'); // may have gone through: checkPayout decides
else await update(referenceId, { status: 'failed', reason: err.code });
}
return findPayout(referenceId);
}
async function balanceFor(payout: Payout): Promise<{ available: number | null }> {
try {
const b = await getBalance();
return { available: b.currency === payout.currency ? Number(b.availableBalance) : null };
} catch {
return { available: null }; // balance unknown: send anyway; MTN refuses what you can't cover
}
}
/** Settles a payout from MTN's answer. Safe to run as often as you like. */
export async function checkPayout(referenceId: string): Promise<Payout | null> {
const payout = await findPayout(referenceId);
if (!payout || !['sending', 'pending', 'unknown'].includes(payout.status)) return payout;
if (payout.status === 'sending' && Date.now() - payout.createdAt < 60_000) return payout; // a send may be in flight right now
let result: Awaited<ReturnType<typeof getTransfer>>;
try {
result = await getTransfer(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.
if (payout.status !== 'pending') await claim(referenceId, payout.status, 'new');
return findPayout(referenceId);
}
const status = String(result.status ?? '').toUpperCase();
if (status === 'SUCCESSFUL') await update(referenceId, { status: 'paid', financialTransactionId: String(result.financialTransactionId ?? '') });
else if (status === 'FAILED') {
const reason = typeof result.reason === 'object' && result.reason ? (result.reason as { code?: unknown }).code : result.reason;
await update(referenceId, { status: 'failed', reason: String(reason ?? 'FAILED') });
} else if (payout.status !== 'pending') await claim(referenceId, payout.status, 'pending');
return findPayout(referenceId);
}
The balance check stops a payout early instead of letting MTN fail it with NOT_ENOUGH_FUNDS. If the balance can’t be read, the payout goes ahead: MTN still refuses what your account can’t cover.
The routes
Both routes check a bearer token in constant time. Put the helper in its own file:
// lib/bearer.ts
import { timingSafeEqual } from 'node:crypto';
/** True if the Authorization header is "Bearer <secret>", and the secret is long enough to be one. */
export function bearerOk(header: string | null, secret: string | undefined): boolean {
if (!secret || secret.length < 16) return false;
const given = Buffer.from(header ?? '');
const expected = Buffer.from(`Bearer ${secret}`);
return given.length === expected.length && timingSafeEqual(given, expected);
}
The admin route creates and sends a payout. In a real app, call createPayout and sendPayout from your own admin pages after your normal login check, and record who did it; the token here stands in for that.
// app/api/admin/payouts/route.ts
import { bearerOk } from '@/lib/bearer';
import { createPayout, sendPayout } from '@/lib/payouts';
export async function POST(request: Request): Promise<Response> {
if (!bearerOk(request.headers.get('authorization'), process.env.PAYOUT_ADMIN_TOKEN)) return new Response('Unauthorized', { status: 401 });
const body = await request.json().catch(() => null);
try {
const payout = await createPayout({ amount: body?.amount, phone: body?.phone, externalId: body?.externalId, note: body?.note, createdBy: 'admin-token' });
const sent = await sendPayout(payout.referenceId);
return Response.json({ referenceId: payout.referenceId, status: sent?.status, reason: sent?.reason ?? null }, { status: 202 });
} catch (err) {
if (err instanceof RangeError) return Response.json({ error: err.message }, { status: 422 });
throw err;
}
}
The check route settles every open payout, and resends the ones MTN never received, with their original reference:
// app/api/cron/check-payouts/route.ts
import { bearerOk } from '@/lib/bearer';
import { checkPayout, payoutsIn, sendPayout } from '@/lib/payouts';
export async function GET(request: Request): Promise<Response> {
if (!bearerOk(request.headers.get('authorization'), process.env.CRON_SECRET)) return new Response('Unauthorized', { status: 401 });
for (const p of await payoutsIn(['sending', 'pending', 'unknown'])) await checkPayout(p.referenceId);
for (const p of await payoutsIn(['new'])) await sendPayout(p.referenceId); // never received by MTN, or waiting for funds
const counts: Record<string, number> = {};
for (const p of await payoutsIn(['new', 'sending', 'pending', 'unknown'])) counts[p.status] = (counts[p.status] ?? 0) + 1;
return Response.json(counts);
}
Call it every minute or two with Authorization: Bearer <CRON_SECRET>. On Vercel, add the path to crons in vercel.json; Vercel sends that header when CRON_SECRET is set. Anywhere else:
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-app.com/api/cron/check-payouts
Test it without calling MTN
The test calls the exported handlers directly and replaces fetch with a fake MTN. Install a TypeScript test runner that understands the @/ path alias:
npm install --save-dev tsx
npx tsx --test tests/payouts.test.ts
// tests/payouts.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { POST as create } from '@/app/api/admin/payouts/route';
import { GET as check } from '@/app/api/cron/check-payouts/route';
import { createPayout, findPayout, sendPayout } from '@/lib/payouts';
Object.assign(process.env, {
MOMO_BASE_URL: 'https://momo.test', MOMO_CURRENCY: 'UGX', MOMO_DISBURSEMENT_SUBSCRIPTION_KEY: 'sub', MOMO_DISBURSEMENT_API_USER: 'user',
MOMO_DISBURSEMENT_API_KEY: 'key', PAYOUT_ADMIN_TOKEN: 'admin-token-for-tests', CRON_SECRET: 'cron-secret-for-tests',
PAYOUT_MAX_AMOUNT: '500000', PAYOUT_DAILY_LIMIT: '100000000',
});
let mtn: Map<string, { status: string; reason?: string }>;
let mode: 'ok' | 'refused' | 'timeout-lost' | 'timeout-received' = 'ok';
let balance = '10000000';
let transfers: { ref: string; body: Record<string, unknown> }[] = [];
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
const headers = (init?.headers ?? {}) as Record<string, string>;
if (path === '/disbursement/token/') return Response.json({ access_token: 'test-token', expires_in: 3600 });
if (path === '/disbursement/v1_0/account/balance') return Response.json({ availableBalance: balance, currency: 'UGX' });
if (path === '/disbursement/v1_0/transfer') {
const ref = headers['X-Reference-Id'];
if (mode === 'timeout-lost') throw new TypeError('fetch failed'); // never reached MTN
transfers.push({ ref, body: JSON.parse(String(init?.body)) });
if (mode === 'refused') return Response.json({ code: 'PAYEE_NOT_FOUND', message: 'Payee not found' }, { status: 400 });
if (mtn.has(ref)) return new Response(null, { status: 409 });
mtn.set(ref, { status: 'PENDING' });
if (mode === 'timeout-received') throw new TypeError('fetch failed'); // reached MTN, answer lost
return new Response(null, { status: 202 });
}
const m = path.match(/^\/disbursement\/v1_0\/transfer\/(.+)$/);
if (m) {
const t = mtn.get(decodeURIComponent(m[1]));
return t ? Response.json({ financialTransactionId: '91827364', ...t }) : Response.json({ code: 'RESOURCE_NOT_FOUND' }, { status: 404 });
}
throw new Error(`unexpected ${path}`);
}) as typeof fetch;
const admin = (body: unknown, token = 'admin-token-for-tests') =>
create(new Request('http://localhost/api/admin/payouts', { method: 'POST', body: JSON.stringify(body), headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' } }));
const runCheck = () => check(new Request('http://localhost/api/cron/check-payouts', { headers: { authorization: 'Bearer cron-secret-for-tests' } }));
async function later<T>(ms: number, fn: () => Promise<T>): Promise<T> {
const now = Date.now;
Date.now = () => now() + ms;
try {
return await fn();
} finally {
Date.now = now;
}
}
beforeEach(() => {
mtn = new Map();
mode = 'ok';
balance = '10000000';
transfers = [];
});
test('a payout is sent once with the reference it was saved with, then settled from MTN', async () => {
const res = await admin({ amount: '25000', phone: '256772123456', externalId: 'refund-1042', note: 'Refund for order 1042' });
const { referenceId, status } = await res.json();
assert.deepEqual([res.status, status], [202, 'pending']);
assert.equal(transfers[0].ref, referenceId);
assert.deepEqual([transfers[0].body.amount, transfers[0].body.currency, transfers[0].body.payee], ['25000', 'UGX', { partyIdType: 'MSISDN', partyId: '256772123456' }]);
mtn.set(referenceId, { status: 'SUCCESSFUL' });
await runCheck();
assert.deepEqual([(await findPayout(referenceId))?.status, (await findPayout(referenceId))?.financialTransactionId], ['paid', '91827364']);
mtn.set(referenceId, { status: 'FAILED', reason: 'INTERNAL_PROCESSING_ERROR' }); // a late, different answer
await runCheck();
assert.equal((await findPayout(referenceId))?.status, 'paid');
});
test('two sends at once call MTN once', async () => {
const p = await createPayout({ amount: '1000', phone: '256772123456', externalId: 'x', createdBy: 'test' });
await Promise.all([sendPayout(p.referenceId), sendPayout(p.referenceId), sendPayout(p.referenceId)]);
assert.equal(transfers.length, 1);
await sendPayout(p.referenceId);
assert.equal(transfers.length, 1);
});
test('a lost request is resent with the SAME reference; a received one is never resent', async () => {
mode = 'timeout-lost';
const lost = (await (await admin({ amount: '1000', phone: '256772123456', externalId: 'a' })).json()).referenceId;
mode = 'timeout-received';
const received = (await (await admin({ amount: '2000', phone: '256772123457', externalId: 'b' })).json()).referenceId;
assert.deepEqual([(await findPayout(lost))?.status, (await findPayout(received))?.status], ['unknown', 'unknown']);
mode = 'ok';
await later(2 * 60_000, runCheck);
assert.deepEqual(transfers.map((t) => t.ref), [received, lost]); // the lost one went out once, with its own reference
assert.deepEqual([(await findPayout(lost))?.status, (await findPayout(received))?.status], ['pending', 'pending']);
});
test('a refusal fails the payout; MTN failures are recorded', async () => {
mode = 'refused';
const { referenceId, status, reason } = await (await admin({ amount: '1000', phone: '256772123456', externalId: 'c' })).json();
assert.deepEqual([status, reason], ['failed', 'PAYEE_NOT_FOUND']);
mode = 'ok';
await runCheck();
assert.equal(transfers.filter((t) => t.ref === referenceId).length, 1);
const other = (await (await admin({ amount: '1000', phone: '256772123456', externalId: 'd' })).json()).referenceId;
mtn.set(other, { status: 'FAILED', reason: 'PAYEE_NOT_ALLOWED_TO_RECEIVE' });
await runCheck();
assert.deepEqual([(await findPayout(other))?.status, (await findPayout(other))?.reason], ['failed', 'PAYEE_NOT_ALLOWED_TO_RECEIVE']);
});
test('not enough balance leaves the payout new until a later run', async () => {
balance = '500';
const { referenceId, status } = await (await admin({ amount: '1000', phone: '256772123456', externalId: 'e' })).json();
assert.equal(status, 'new');
assert.equal(transfers.length, 0);
balance = '10000000';
await runCheck();
assert.equal((await findPayout(referenceId))?.status, 'pending');
});
test('the admin route needs the token and enforces limits', async () => {
assert.equal((await admin({ amount: '1000', phone: '256772123456' }, 'wrong-token-of-length')).status, 401);
assert.equal((await admin({ amount: '600000', phone: '256772123456' })).status, 422); // over the per-payout limit
assert.equal((await admin({ amount: '10.555', phone: '256772123456' })).status, 422);
assert.equal((await admin({ amount: '1000', phone: '+256 772' })).status, 422);
assert.equal((await check(new Request('http://localhost/api/cron/check-payouts'))).status, 401);
process.env.PAYOUT_DAILY_LIMIT = '1';
assert.equal((await admin({ amount: '1', phone: '256772123456' })).status, 422); // today's earlier payouts used it up
process.env.PAYOUT_DAILY_LIMIT = '100000000';
assert.equal(transfers.length, 0);
});
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 |
Your disbursement account is short | The balance check normally stops this; top up and the next check run sends it |
FAILED with PAYEE_NOT_FOUND |
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 key, API user and key from the MTN MoMo Partner Portal, the production base URL, your country’s
MOMO_TARGET_ENV(for examplemtnuganda) and currency. - Replace the
Mapinlib/payouts.tswith your database:reference_idunique,claimandupdateas conditional updates. - Replace the admin token with your real login, and record who created each payout.
- Set
PAYOUT_MAX_AMOUNTandPAYOUT_DAILY_LIMITto what your business actually needs. - Run the check route every minute or two, and reconcile daily against your MoMo statement.
Paying M-Pesa wallets? See M-Pesa B2C payouts in Next.js. Comparing payout APIs? See mobile money payouts. Want payouts already built with an audit trail and admin view? The Next.js starter kit has them.
Frequently asked questions
Can I send MoMo payouts from a Next.js server action?
Yes, but treat it like the admin route in this guide: check that the caller is an authorised admin, apply limits, and claim the payout with a conditional update before calling MTN. Anyone who can reach that code can send your money.
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.
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/).
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.