Skip to content

MTN MoMo · Next.js

MTN MoMo Disbursements in Next.js App Router: Safe Payouts

Send MTN MoMo payouts from a Next.js App Router app: an admin transfer route, status checks, a balance check and payouts never sent twice.

Published

Skip the boilerplate → Generate every Disbursements request in this guide with the MoMo Request Builder.

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:

  1. Create the X-Reference-Id once, with the payout, and save it before sending. It is the transfer’s identity at MTN. A retry always reuses it.
  2. Claim the payout before sending. Move it from new to sending with a conditional update. Only the request that wins the claim calls MTN.
  3. After a timeout, ask before resending. Only a 404 Not Found proves MTN never created the transfer, and even then you resend with the same reference. MTN rejects a second request with that reference with 409 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

  1. On momodeveloper.mtn.com, subscribe to the Disbursements product and copy its primary key.
  2. 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:

  • claim moves 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.
  • update refuses to change a paid or failed payout: ... 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 example mtnuganda) and currency.
  • Replace the Map in lib/payouts.ts with your database: reference_id unique, claim and update as conditional updates.
  • Replace the admin token with your real login, and record who created each payout.
  • Set PAYOUT_MAX_AMOUNT and PAYOUT_DAILY_LIMIT to 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.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.