This guide adds MTN Mobile Money payments to a Next.js App Router app with the MoMo Collections API (Request to Pay): the customer gets a prompt on their phone and approves it with their PIN. You’ll build:
lib/momo.ts: token caching, Request to Pay and the status check,lib/momo-payments.ts: start a payment (saved first, with a reference you choose) and confirm it once from MTN’s own answer,- route handlers to start a payment, poll it, and receive MTN’s callback (POST or PUT),
- a reconcile route for callbacks that never arrive, protected by a secret,
- tests that run the whole flow 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.
Before you start
- Sign up at momodeveloper.mtn.com and subscribe to the Collections product.
- Copy your primary subscription key.
- Create a sandbox API user and API key. Steps 1 and 2 of the MoMo Request Builder generate the cURL commands for you. The
providerCallbackHostyou register there is the only host MTN sends callbacks to.
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_COUNTRY_CODE=256
MOMO_SUBSCRIPTION_KEY=your-subscription-key
MOMO_API_USER=your-api-user-uuid
MOMO_API_KEY=your-api-key
MOMO_CALLBACK_BASE_URL=https://your-tunnel.example.com
CRON_SECRET=a-long-random-string
The sandbox only accepts EUR. In production you’ll use your country’s currency, for example UGX.
The MoMo client
// lib/momo.ts
const env = process.env;
const base = () => (env.MOMO_BASE_URL ?? 'https://sandbox.momodeveloper.mtn.com').replace(/\/+$/, '');
/** Network failures and 5xx answers: MTN may or may not have acted. */
export class MomoUnclearError extends Error {}
/** MTN refused the request (4xx): nothing was sent to the customer. */
export class MomoRejectedError extends Error {}
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 MomoUnclearError(`MoMo unreachable: ${(err as Error).message}`);
}
}
async function getToken(): Promise<string> {
if (token && Date.now() < token.expiresAt - 60_000) return token.value;
const basic = Buffer.from(`${env.MOMO_API_USER}:${env.MOMO_API_KEY}`).toString('base64');
const res = await call('/collection/token/', {
method: 'POST', // no body: MoMo wants Content-Length: 0
headers: { Authorization: `Basic ${basic}`, 'Ocp-Apim-Subscription-Key': env.MOMO_SUBSCRIPTION_KEY ?? '' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.access_token) throw new MomoUnclearError(`MoMo token failed: HTTP ${res.status}`);
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_SUBSCRIPTION_KEY ?? '',
'X-Target-Environment': env.MOMO_TARGET_ENV ?? 'sandbox',
...extra,
};
}
/** "0772 123456", "+256772123456", "772123456" -> "256772123456". Numbers typed with + keep their own code. */
export function normalisePhone(raw: unknown, countryCode = env.MOMO_COUNTRY_CODE ?? '256'): string {
const text = String(raw ?? '').trim();
let digits = text.replace(/\D/g, '');
const international = text.startsWith('+') || digits.startsWith('00');
if (digits.startsWith('00')) digits = digits.slice(2);
if (digits.startsWith(`${countryCode}0`)) digits = countryCode + digits.slice(countryCode.length + 1);
else if (!international) digits = digits.startsWith('0') ? countryCode + digits.slice(1) : digits.startsWith(countryCode) ? digits : countryCode + digits;
if (!/^\d{10,15}$/.test(digits)) throw new RangeError(`Not a valid mobile number: ${text}`);
return digits;
}
export interface RequestToPay {
referenceId: string; // a UUID v4 you choose: MoMo's idempotency key
amount: string; // a string, no trailing ".00"
currency: string;
phone: string;
orderId: string;
callbackUrl?: string;
}
/** Sends the prompt. MTN answers 202 Accepted, which only means it received the request. */
export async function requestToPay(r: RequestToPay): Promise<void> {
const extra: Record<string, string> = { 'X-Reference-Id': r.referenceId, 'Content-Type': 'application/json' };
if (r.callbackUrl?.startsWith('https://')) extra['X-Callback-Url'] = r.callbackUrl;
const res = await call('/collection/v1_0/requesttopay', {
method: 'POST',
headers: await headers(extra),
body: JSON.stringify({
amount: r.amount,
currency: r.currency,
externalId: r.orderId,
payer: { partyIdType: 'MSISDN', partyId: r.phone },
payerMessage: `Payment for order ${r.orderId}`.slice(0, 160),
payeeNote: `Order ${r.orderId}`.slice(0, 160),
}),
});
if (res.status === 202 || res.status === 409) return; // 409: this reference was already received
if (res.status >= 500) throw new MomoUnclearError(`MoMo HTTP ${res.status}`);
const data = await res.json().catch(() => ({}));
throw new MomoRejectedError(`MoMo rejected the request: ${data.code ?? res.status} ${data.message ?? ''}`.trim());
}
export interface MomoStatus {
httpStatus: number;
status?: string; // PENDING, SUCCESSFUL, FAILED
amount?: string;
currency?: string;
financialTransactionId?: string;
reason?: unknown;
}
/** The source of truth for a payment. */
export async function getStatus(referenceId: string): Promise<MomoStatus> {
const res = await call(`/collection/v1_0/requesttopay/${encodeURIComponent(referenceId)}`, { headers: await headers() });
const data = await res.json().catch(() => ({}));
return { httpStatus: res.status, ...(data && typeof data === 'object' ? data : {}) };
}
Not sure how numbers are written in your market? Check them in the African Phone Number Formatter.
Start and confirm payments
Your database goes here. This file keeps payments in a Map so the guide runs on its own. When you replace the store functions, keep the rule in settle: change a row only if it is still pending, in one statement, for example UPDATE payments SET status = $2 WHERE reference_id = $1 AND status = 'pending'.
// lib/momo-payments.ts
import { randomUUID } from 'node:crypto';
import { getStatus, MomoRejectedError, MomoUnclearError, normalisePhone, requestToPay } from './momo';
export interface Payment {
referenceId: string;
orderId: string;
amount: string; // as sent to MTN, e.g. "25000" or "12.5"
currency: string;
phone: string;
status: 'pending' | 'paid' | 'failed';
reason?: string;
financialTransactionId?: string;
createdAt: number;
}
// ── Replace with your database ──────────────────────────────────────────────
const rows = new Map<string, Payment>();
async function insert(p: Payment): Promise<void> {
rows.set(p.referenceId, { ...p });
}
export async function findPayment(referenceId: string): Promise<Payment | null> {
const p = rows.get(referenceId);
return p ? { ...p } : null;
}
export async function pendingOlderThan(ms: number): Promise<Payment[]> {
const cutoff = Date.now() - ms;
return [...rows.values()].filter((p) => p.status === 'pending' && p.createdAt < cutoff).slice(0, 100);
}
/** Changes a payment only while it is still pending. True if this call changed it. */
async function settle(referenceId: string, fields: Partial<Payment>): Promise<boolean> {
const p = rows.get(referenceId);
if (!p || p.status !== 'pending') return false;
rows.set(referenceId, { ...p, ...fields });
return true;
}
// ────────────────────────────────────────────────────────────────────────────
/** "25000" -> "25000", "12.50" -> "12.5". Throws RangeError for anything else. */
export function formatAmount(raw: unknown): string {
const text = String(raw ?? '').trim();
if (!/^\d+(\.\d{1,2})?$/.test(text) || Number(text) <= 0) throw new RangeError('Amount must be a positive number');
return text.includes('.') ? text.replace(/\.?0+$/, '') : text.replace(/^0+(?=\d)/, '');
}
/** Saves the payment, then sends the prompt. Throws RangeError for bad input. */
export async function startPayment(orderId: string, rawAmount: unknown, rawPhone: unknown): Promise<Payment> {
const amount = formatAmount(rawAmount);
const phone = normalisePhone(rawPhone);
const payment: Payment = {
referenceId: randomUUID(), orderId: orderId.slice(0, 64), amount, currency: process.env.MOMO_CURRENCY ?? 'EUR', phone,
status: 'pending', createdAt: Date.now(),
};
await insert(payment); // saved BEFORE MTN is called, with the reference MTN will know it by
const callbackBase = (process.env.MOMO_CALLBACK_BASE_URL ?? '').replace(/\/+$/, '');
try {
await requestToPay({ ...payment, callbackUrl: callbackBase && `${callbackBase}/api/webhooks/momo?ref=${payment.referenceId}` });
} catch (err) {
if (err instanceof MomoRejectedError) await settle(payment.referenceId, { status: 'failed', reason: 'REJECTED' });
else if (err instanceof MomoUnclearError) console.warn('MoMo may have received', payment.referenceId, err.message); // stays pending
else throw err;
}
return (await findPayment(payment.referenceId))!;
}
/** Asks MTN for the real status and settles the payment once. Returns the new status only on the call that changed it. */
export async function confirmPayment(referenceId: string): Promise<Payment['status'] | null> {
const payment = await findPayment(referenceId);
if (!payment || payment.status !== 'pending') return null; // unknown, or already settled
const r = await getStatus(referenceId);
if (r.httpStatus === 404) {
// MTN never received it. Give it two minutes in case it's still being registered.
if (Date.now() - payment.createdAt < 120_000) return null;
return (await settle(referenceId, { status: 'failed', reason: 'RESOURCE_NOT_FOUND' })) ? 'failed' : null;
}
if (r.httpStatus >= 300) return null; // ask again later
const status = String(r.status ?? '').toUpperCase();
if (status === 'SUCCESSFUL') {
if (String(r.amount) !== payment.amount || String(r.currency).toUpperCase() !== payment.currency) {
console.error('MoMo amount or currency mismatch', referenceId, r.amount, r.currency); // a person must look
return null;
}
return (await settle(referenceId, { status: 'paid', financialTransactionId: String(r.financialTransactionId ?? '') })) ? 'paid' : null;
}
if (status === 'FAILED') {
const reason = typeof r.reason === 'object' && r.reason ? (r.reason as { code?: unknown }).code : r.reason;
return (await settle(referenceId, { status: 'failed', reason: String(reason ?? 'FAILED').slice(0, 64) })) ? 'failed' : null;
}
return null; // PENDING: a later callback or the reconcile route finishes it
}
The failure reason is MTN’s code, such as APPROVAL_REJECTED or NOT_ENOUGH_FUNDS. The MoMo Callback Validator explains every code.
The route handlers
Start a payment from your checkout page with fetch('/api/payments/momo', …), then poll /api/payments/momo/<reference> every few seconds until the status is no longer pending.
// app/api/payments/momo/route.ts
import { startPayment } from '@/lib/momo-payments';
export async function POST(request: Request): Promise<Response> {
const body = await request.json().catch(() => null);
// In a real shop, take the amount from your order record, never from the browser.
try {
const payment = await startPayment(String(body?.orderId ?? ''), body?.amount, body?.phone);
if (payment.status === 'failed') {
return Response.json({ error: 'MTN MoMo could not send the payment prompt. Check the number and try again.' }, { status: 502 });
}
return Response.json({ reference: payment.referenceId, message: 'Check your phone and enter your MoMo PIN.' }, { status: 202 });
} catch (err) {
if (err instanceof RangeError) return Response.json({ error: err.message }, { status: 422 });
throw err;
}
}
// app/api/payments/momo/[reference]/route.ts
import { findPayment } from '@/lib/momo-payments';
export async function GET(_request: Request, { params }: { params: Promise<{ reference: string }> }): Promise<Response> {
const payment = await findPayment((await params).reference);
if (!payment) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json({ status: payment.status });
}
The callback route reads only ref from its own URL, which you set when you started the payment, and asks MTN. It never reads the body, so a forged callback can’t mark anything paid.
// app/api/webhooks/momo/route.ts
import { MomoUnclearError } from '@/lib/momo';
import { confirmPayment } from '@/lib/momo-payments';
async function handle(request: Request): Promise<Response> {
const ref = new URL(request.url).searchParams.get('ref') ?? '';
if (/^[0-9a-f-]{36}$/i.test(ref)) {
try {
const changed = await confirmPayment(ref);
if (changed === 'paid') {
// Fulfil the order here, or queue a job that does. This runs once per payment.
console.info('MoMo payment confirmed', ref);
}
} catch (err) {
if (!(err instanceof MomoUnclearError)) throw err;
console.warn('Could not confirm; the reconcile route will retry', ref);
}
}
return new Response(null, { status: 200 }); // always acknowledge
}
export const POST = handle;
export const PUT = handle; // MTN's callback method varies, so accept both
Catch callbacks that never arrive
// app/api/cron/reconcile-momo/route.ts
import { timingSafeEqual } from 'node:crypto';
import { MomoUnclearError } from '@/lib/momo';
import { confirmPayment, pendingOlderThan } from '@/lib/momo-payments';
function authorised(header: string | null): boolean {
const secret = process.env.CRON_SECRET ?? '';
const given = Buffer.from(header ?? '');
const expected = Buffer.from(`Bearer ${secret}`);
return secret.length >= 16 && given.length === expected.length && timingSafeEqual(given, expected);
}
export async function GET(request: Request): Promise<Response> {
if (!authorised(request.headers.get('authorization'))) return new Response('Unauthorized', { status: 401 });
const counts: Record<string, number> = { paid: 0, failed: 0, pending: 0 };
for (const p of await pendingOlderThan(60_000)) {
try {
counts[(await confirmPayment(p.referenceId)) ?? 'pending'] += 1;
} catch (err) {
if (!(err instanceof MomoUnclearError)) throw err;
counts.pending += 1;
}
}
return Response.json(counts);
}
Call it every few minutes 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/reconcile-momo
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/momo.test.ts
// tests/momo.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { POST as start } from '@/app/api/payments/momo/route';
import { GET as status } from '@/app/api/payments/momo/[reference]/route';
import { POST as callbackPost, PUT as callbackPut } from '@/app/api/webhooks/momo/route';
import { GET as reconcile } from '@/app/api/cron/reconcile-momo/route';
import { findPayment, formatAmount } from '@/lib/momo-payments';
import { normalisePhone } from '@/lib/momo';
Object.assign(process.env, {
MOMO_BASE_URL: 'https://momo.test', MOMO_CURRENCY: 'UGX', MOMO_COUNTRY_CODE: '256', MOMO_SUBSCRIPTION_KEY: 'sub', MOMO_API_USER: 'user',
MOMO_API_KEY: 'key', MOMO_CALLBACK_BASE_URL: 'https://shop.test', CRON_SECRET: 'cron-secret-for-tests',
});
let mtn: Record<string, { status: string; amount?: string; currency?: string; reason?: unknown }> = {};
let rtp: 'ok' | 'rejected' | 'down' = 'ok';
let sent: { headers: Record<string, string>; body: Record<string, unknown> }[] = [];
let lookups = 0;
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 === '/collection/token/') return Response.json({ access_token: 'test-token', expires_in: 3600 });
if (path === '/collection/v1_0/requesttopay') {
if (rtp === 'down') throw new TypeError('fetch failed');
sent.push({ headers, body: JSON.parse(String(init?.body)) });
if (rtp === 'rejected') return Response.json({ code: 'PAYER_NOT_FOUND', message: 'Payer not found' }, { status: 400 });
return new Response(null, { status: 202 });
}
const m = path.match(/^\/collection\/v1_0\/requesttopay\/(.+)$/);
if (m) {
lookups += 1;
const tx = mtn[decodeURIComponent(m[1])];
return tx ? Response.json({ financialTransactionId: '7342918650', ...tx }) : Response.json({ code: 'RESOURCE_NOT_FOUND' }, { status: 404 });
}
throw new Error(`unexpected ${path}`);
}) as typeof fetch;
const json = (body: unknown) => ({ method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' } });
async function pay(phone = '0772 123456', amount: unknown = '25000') {
const res = await start(new Request('http://localhost/api/payments/momo', json({ orderId: 'order-1042', amount, phone })));
const body = await res.json();
return { res, body, payment: body.reference ? await findPayment(body.reference) : null };
}
const notify = (ref: string, handler = callbackPut, body: unknown = { status: 'SUCCESSFUL' }) =>
handler(new Request(`https://shop.test/api/webhooks/momo?ref=${ref}`, { method: 'PUT', body: JSON.stringify(body) }));
beforeEach(() => {
mtn = {};
rtp = 'ok';
sent = [];
lookups = 0;
});
test('the request carries the reference, callback URL and payer MTN expects', async () => {
const { res, payment } = await pay();
assert.equal(res.status, 202);
const { headers, body } = sent[0];
assert.equal(headers['X-Reference-Id'], payment!.referenceId);
assert.equal(headers['X-Callback-Url'], `https://shop.test/api/webhooks/momo?ref=${payment!.referenceId}`);
assert.deepEqual([headers.Authorization, headers['X-Target-Environment'], headers['Ocp-Apim-Subscription-Key']], ['Bearer test-token', 'sandbox', 'sub']);
assert.deepEqual([body.amount, body.currency, body.payer], ['25000', 'UGX', { partyIdType: 'MSISDN', partyId: '256772123456' }]);
});
test('the callback body is never trusted: MTN decides', async () => {
const { payment } = await pay();
mtn[payment!.referenceId] = { status: 'PENDING' };
assert.equal((await notify(payment!.referenceId)).status, 200);
assert.equal((await findPayment(payment!.referenceId))?.status, 'pending');
mtn[payment!.referenceId] = { status: 'SUCCESSFUL', amount: '25000', currency: 'UGX' };
const logged: unknown[] = [];
const info = console.info;
console.info = (...args: unknown[]) => void logged.push(args);
try {
await Promise.all([notify(payment!.referenceId), notify(payment!.referenceId, callbackPost), notify(payment!.referenceId)]);
} finally {
console.info = info;
}
assert.equal(logged.length, 1); // fulfilled once
const res = await status(new Request('http://localhost'), { params: Promise.resolve({ reference: payment!.referenceId }) });
assert.deepEqual(await res.json(), { status: 'paid' });
});
test('a different amount or currency is not paid; failed is final', async () => {
const { payment } = await pay();
mtn[payment!.referenceId] = { status: 'SUCCESSFUL', amount: '250', currency: 'UGX' };
await notify(payment!.referenceId);
assert.equal((await findPayment(payment!.referenceId))?.status, 'pending');
mtn[payment!.referenceId] = { status: 'FAILED', reason: 'APPROVAL_REJECTED' };
await notify(payment!.referenceId);
assert.deepEqual([(await findPayment(payment!.referenceId))?.status, (await findPayment(payment!.referenceId))?.reason], ['failed', 'APPROVAL_REJECTED']);
mtn[payment!.referenceId] = { status: 'SUCCESSFUL', amount: '25000', currency: 'UGX' };
await notify(payment!.referenceId);
assert.equal((await findPayment(payment!.referenceId))?.status, 'failed');
});
test('a timeout stays pending; a rejection fails; bad input never reaches MTN', async () => {
rtp = 'down';
const timedOut = await pay();
assert.deepEqual([timedOut.res.status, timedOut.payment?.status], [202, 'pending']);
rtp = 'rejected';
assert.equal((await pay()).res.status, 502);
rtp = 'ok';
assert.equal((await pay('12', '25000')).res.status, 422);
assert.equal((await pay('0772123456', '-5')).res.status, 422);
assert.equal((await pay('0772123456', '1e3')).res.status, 422);
assert.equal(sent.length, 1);
});
test('unknown or malformed references are acknowledged and ignored', async () => {
assert.equal((await notify('not-a-uuid')).status, 200);
assert.equal((await notify('3f2a1b4c-0000-4000-8000-000000000000')).status, 200);
assert.equal(lookups, 0);
});
test('reconcile needs the secret; settles old payments; 404 fails only after two minutes', async () => {
rtp = 'down';
const lost = (await pay()).payment!; // MTN never got it: 404
rtp = 'ok';
const done = (await pay()).payment!;
mtn[done.referenceId] = { status: 'SUCCESSFUL', amount: '25000', currency: 'UGX' };
const auth = { headers: { authorization: 'Bearer cron-secret-for-tests' } };
assert.equal((await reconcile(new Request('http://localhost'))).status, 401);
const old = Date.now;
try {
Date.now = () => old() + 90_000;
await reconcile(new Request('http://localhost', auth));
assert.deepEqual([(await findPayment(lost.referenceId))?.status, (await findPayment(done.referenceId))?.status], ['pending', 'paid']);
Date.now = () => old() + 5 * 60_000;
await reconcile(new Request('http://localhost', auth));
assert.equal((await findPayment(lost.referenceId))?.status, 'failed');
} finally {
Date.now = old;
}
});
test('phone numbers and amounts', () => {
assert.deepEqual(['0772 123456', '+256772123456', '772123456', '+256 (0)772 123456', '+46733123451'].map((n) => normalisePhone(n, '256')),
['256772123456', '256772123456', '256772123456', '256772123456', '46733123451']);
assert.deepEqual(['25000', '12.50', '7.0'].map(formatAmount), ['25000', '12.5', '7']);
});
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
401 on the token |
Wrong API user or key, or a subscription key from another product | Use the Collections subscription key, and the user and key created with it |
| Request to Pay refused, or no callback ever | The callback host isn’t your providerCallbackHost |
Use the registered host, over HTTPS, in MOMO_CALLBACK_BASE_URL |
500 naming NOT_ALLOWED_TARGET_ENVIRONMENT |
MOMO_TARGET_ENV doesn’t match your keys |
sandbox for sandbox; your country’s value (for example mtnuganda) in production |
| Callback never arrives | Not public, wrong host, or middleware redirects it | Exclude /api/webhooks from the middleware matcher; the reconcile route settles payments meanwhile |
| Sandbox rejects the currency | The sandbox only takes EUR |
Keep MOMO_CURRENCY=EUR until production |
Route segment config "runtime" is not compatible with nextConfig.cacheComponents |
export const runtime in a Next.js 16 app |
Delete it: route handlers use Node.js by default |
Every MoMo error code is explained in the MoMo error reference.
Test against the sandbox
Keep MOMO_CURRENCY=EUR and use MTN’s test numbers, typed with a + so they keep their own country code: +46733123450 simulates a failed payment and +46733123451 a rejected one, while numbers not on MTN’s list simulate success. The full list is on the Request Builder page.
Going to production
- Use the API user and key from the MTN MoMo Partner Portal, and the production base URL MTN gives you.
- Set
MOMO_TARGET_ENVto your country (for examplemtnuganda) andMOMO_CURRENCYto the local currency (for exampleUGX). - Set
MOMO_CALLBACK_BASE_URLto your public HTTPS address on the registered host. - Replace the
Mapinlib/momo-payments.tswith your database, keeping the conditional update. - Schedule the reconcile route, and alert on payments stuck in
pendingfor more than an hour.
Want this already built, combined with M-Pesa, Airtel Money, Flutterwave, Paystack, Pesapal and payouts? The Next.js starter kit has it ready.
Frequently asked questions
Does the MoMo callback arrive as POST or PUT in Next.js?
Developers report both, so export both POST and PUT from the callback route handler. Each one only reads the reference from the URL and asks MTN for the real status.
Can I call the MoMo API from a Next.js client component?
No. The subscription key and API key must stay on the server. Start the payment in a route handler (or a server action) and let the browser poll your own route for the status.
What should I do when Request to Pay times out?
Keep the payment pending. You chose the X-Reference-Id before sending, so you can ask GET /requesttopay/{id} later. MTN answers 404 if the request never arrived; only then is it safe to fail it.
Why does MoMo never call my Next.js callback?
MTN only sends callbacks to the host registered as providerCallbackHost for your API user, and it must be reachable from the internet. On localhost, use a tunnel, and exclude the callback path from auth middleware.