This guide takes you from an empty MTN MoMo developer account to a working payment flow in Node.js. You’ll build a small module that gets and caches access tokens, sends a Request to Pay, checks its status, and handles callbacks safely.
Everything uses Node’s built-in fetch (Node 18+), so there are no SDK dependencies to keep up to date.
Before you start
- Sign up at momodeveloper.mtn.com and subscribe to the Collections product.
- Copy the primary subscription key from your profile. This is your
Ocp-Apim-Subscription-Key. - Create a sandbox API user and API key. The quickest way is steps 1 and 2 of the MoMo Request Builder, which generates the cURL commands for you.
Put the credentials in a .env file and never commit it:
MOMO_BASE_URL=https://sandbox.momodeveloper.mtn.com
MOMO_TARGET_ENV=sandbox
MOMO_CURRENCY=EUR
MOMO_SUBSCRIPTION_KEY=your-collections-subscription-key
MOMO_API_USER=your-api-user-uuid
MOMO_API_KEY=your-api-key
Get and cache an access token
The token endpoint uses HTTP Basic auth with your API user and API key. Tokens expire, so cache them in memory and refresh shortly before expiry. Requesting a new token for every payment is slow and can hit rate limits.
// momo.js
const {
MOMO_BASE_URL, MOMO_TARGET_ENV, MOMO_CURRENCY,
MOMO_SUBSCRIPTION_KEY, MOMO_API_USER, MOMO_API_KEY,
} = process.env;
let cached = { token: null, expiresAt: 0 };
export async function getToken() {
// Refresh 60 seconds early so a token never expires mid-request.
if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token;
const basic = Buffer.from(`${MOMO_API_USER}:${MOMO_API_KEY}`).toString('base64');
const res = await fetch(`${MOMO_BASE_URL}/collection/token/`, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Ocp-Apim-Subscription-Key': MOMO_SUBSCRIPTION_KEY,
'Content-Length': '0',
},
});
if (!res.ok) throw new Error(`MoMo token failed: ${res.status} ${await res.text()}`);
const { access_token, expires_in } = await res.json();
cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
return access_token;
}
Send a Request to Pay
Each payment needs a new UUID v4 as X-Reference-Id. That UUID is the transaction ID, so save it with your order before you send the request. If your server crashes after sending, you can still look the payment up.
import { randomUUID } from 'node:crypto';
export async function requestToPay({ amount, phone, orderId, callbackUrl }) {
const referenceId = randomUUID();
const headers = {
Authorization: `Bearer ${await getToken()}`,
'X-Reference-Id': referenceId,
'X-Target-Environment': MOMO_TARGET_ENV,
'Ocp-Apim-Subscription-Key': MOMO_SUBSCRIPTION_KEY,
'Content-Type': 'application/json',
};
if (callbackUrl) headers['X-Callback-Url'] = callbackUrl;
const res = await fetch(`${MOMO_BASE_URL}/collection/v1_0/requesttopay`, {
method: 'POST',
headers,
body: JSON.stringify({
amount: String(amount), // must be a string
currency: MOMO_CURRENCY, // EUR in the sandbox
externalId: orderId, // your own reference
payer: { partyIdType: 'MSISDN', partyId: normalisePhone(phone) },
payerMessage: `Payment for order ${orderId}`,
payeeNote: `Order ${orderId}`,
}),
});
if (res.status !== 202) throw new Error(`Request to Pay failed: ${res.status} ${await res.text()}`);
return referenceId;
}
// "+256 772 123456" or "0772123456" → "256772123456"
export function normalisePhone(raw, countryCode = '256') {
const digits = raw.replace(/\D/g, '');
return digits.startsWith('0') ? countryCode + digits.slice(1) : digits;
}
A 202 Accepted is not a payment. It means MTN has sent a prompt to the customer’s phone. The money moves only after the customer enters their PIN.
Check the payment status
Look the payment up with the same reference ID. The status field is PENDING, SUCCESSFUL or FAILED.
export async function getPaymentStatus(referenceId) {
const res = await fetch(`${MOMO_BASE_URL}/collection/v1_0/requesttopay/${referenceId}`, {
headers: {
Authorization: `Bearer ${await getToken()}`,
'X-Target-Environment': MOMO_TARGET_ENV,
'Ocp-Apim-Subscription-Key': MOMO_SUBSCRIPTION_KEY,
},
});
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
return res.json(); // { status, financialTransactionId, reason, ... }
}
If you poll instead of relying on callbacks, back off between checks (for example 5s, 10s, 20s) and stop after a few minutes. Customers who ignore the prompt leave the payment PENDING until it times out.
Handle callbacks safely
If you pass X-Callback-Url, MTN notifies that URL when the payment finishes. Two rules keep this safe:
- Accept both POST and PUT. The callback method isn’t consistently documented, so handle both.
- Don’t trust the body. Anyone can send a request to your callback URL. Use the callback only as a signal, then confirm the payment with
getPaymentStatus.
// server.js (Express)
import express from 'express';
import { getPaymentStatus } from './momo.js';
const app = express();
app.use(express.json());
async function momoCallback(req, res) {
res.sendStatus(200); // acknowledge fast; do the work after
const referenceId = req.body?.referenceId ?? req.query.referenceId;
if (!referenceId) return;
const payment = await getPaymentStatus(referenceId); // the source of truth
if (payment.status === 'SUCCESSFUL') {
// markOrderPaid(payment.externalId, payment.financialTransactionId)
// Make this idempotent: the same callback can arrive more than once.
}
}
app.post('/webhooks/momo', momoCallback);
app.put('/webhooks/momo', momoCallback);
app.listen(3000);
Test with sandbox numbers
The sandbox simulates outcomes based on the payer number. For example, 46733123450 fails and 46733123451 is rejected, while numbers that aren’t on MTN’s test list simulate success. The full table is on the Request Builder page. Test every outcome before you apply to go live. MTN reviews how your integration handles failures.
Going to production
When MTN approves you for production:
- Use the API user and API key from the Partner Portal. Sandbox provisioning endpoints don’t exist in production.
- Change
MOMO_BASE_URLto the production host MTN gives you, andMOMO_TARGET_ENVto your country (for examplemtnuganda). - Change
MOMO_CURRENCYto the local currency (for exampleUGX). - Serve your callback URL over HTTPS on the host you registered.
No code changes are needed. Only your environment variables change, which is exactly why the code reads everything from process.env.
Frequently asked questions
Is a 202 Accepted from Request to Pay a successful payment?
No. 202 only means MTN accepted the request and sent a prompt to the customer. The payment is complete only when the status endpoint (or the callback) reports SUCCESSFUL.
How long does an MTN MoMo access token last?
The token response includes expires_in, typically 3600 seconds. Cache the token and refresh it a minute or two before it expires instead of requesting a new one for every payment.
Can I trust the MoMo callback body?
Treat it as a notification, not proof. When a callback arrives, call the status endpoint with the same reference ID and fulfil the order based on that response.
Why do I get 400 Bad Request in the sandbox?
Check that X-Reference-Id is a UUID v4, the currency is EUR, the amount is sent as a string and the phone number has no plus sign or leading zero.