How to Integrate MTN MoMo Payments Into a Node.js App
If you are building a SaaS product or marketplace for users in Ghana, Nigeria, Côte d'Ivoire, Uganda, or Cameroon, your payment integration story does not start with Stripe. It starts with a USSD code and a mobile wallet. MTN Mobile Money processes hundreds of millions of transactions annually across West and Central Africa, and yet most payment integration tutorials still treat it as an afterthought. This guide fixes that.
By the end, you will have a working Node.js integration against the MTN MoMo sandbox, a reliable webhook handler, and a clear map of the error cases that quietly break production apps.
Understanding the MoMo API Landscape
MTN exposes its Mobile Money platform through the MoMo API, hosted on the Moesif-based developer portal at momodeveloper.mtn.com. The API is organized into products:
- Collections — request a payment from a subscriber (the most common use case)
- Disbursements — push money out to a subscriber (payouts, refunds)
- Remittances — cross-border transfers
For most SaaS builders, Collections is where you start. A user approves a debit from their MoMo wallet, and the funds land in your merchant wallet. Think of it as the African equivalent of Stripe's Payment Intents.
Step 1: Sandbox Setup
Head to momodeveloper.mtn.com, create an account, and subscribe to the Collections product. MTN will issue you a Subscription Key (called Ocp-Apim-Subscription-Key). This key authenticates every request at the API gateway level.
Next, generate your sandbox credentials:
# Generate a User ID (UUID v4 format)
curl -X POST https://sandbox.momodeveloper.mtn.com/v1_0/apiuser \
-H "X-Reference-Id: YOUR_UUID_HERE" \
-H "Ocp-Apim-Subscription-Key: YOUR_SUBSCRIPTION_KEY" \
-H "Content-Type: application/json" \
-d '{"providerCallbackHost": "https://your-callback-url.com"}'
# Generate an API Key for that User
curl -X POST https://sandbox.momodeveloper.mtn.com/v1_0/apiuser/YOUR_UUID_HERE/apikey \
-H "Ocp-Apim-Subscription-Key: YOUR_SUBSCRIPTION_KEY"
Store the returned apiKey alongside your User ID. Together, they form the credentials you will Base64-encode to obtain a Bearer token for subsequent requests.
Step 2: Requesting a Collection (Charge a User)
Every payment request in MoMo Collections follows a two-step pattern: initiate, then poll or wait for a callback.
Here is a minimal Node.js implementation using the native fetch API (Node 18+):
const { v4: uuidv4 } = require('uuid');
async function getMoMoToken(userId, apiKey, subscriptionKey) {
const credentials = Buffer.from(`${userId}:${apiKey}`).toString('base64');
const res = await fetch(
'https://sandbox.momodeveloper.mtn.com/collection/token/',
{
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
'Ocp-Apim-Subscription-Key': subscriptionKey,
},
}
);
const data = await res.json();
return data.access_token; // Bearer token, valid for 3600 seconds
}
async function requestToPay({ amount, currency, phone, note, token, subscriptionKey }) {
const referenceId = uuidv4();
const res = await fetch(
'https://sandbox.momodeveloper.mtn.com/collection/v1_0/requesttopay',
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'X-Reference-Id': referenceId,
'X-Target-Environment': 'sandbox',
'Ocp-Apim-Subscription-Key': subscriptionKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: String(amount),
currency,
externalId: uuidv4(),
payer: { partyIdType: 'MSISDN', partyId: phone },
payerMessage: note,
payeeNote: note,
}),
}
);
if (res.status === 202) return referenceId; // Accepted — now poll or await webhook
throw new Error(`MoMo request failed: ${res.status}`);
}
The 202 Accepted response does not mean the user has paid. It means the request has been queued. Payment confirmation arrives asynchronously — via polling the /requesttopay/{referenceId} endpoint or via a webhook callback.
Step 3: Handling Webhooks Reliably
The MoMo API posts a callback to your providerCallbackHost when a transaction reaches a terminal state (SUCCESSFUL, FAILED, or REJECTED). Here is a minimal Express handler:
app.post('/momo/callback', express.json(), (req, res) => {
const { referenceId, status, financialTransactionId } = req.body;
if (status === 'SUCCESSFUL') {
// Update your order/invoice record in the database
markOrderAsPaid(referenceId, financialTransactionId);
} else {
// Log failed/rejected transactions for retry logic
handlePaymentFailure(referenceId, status);
}
res.sendStatus(200); // Always ACK — MTN retries on non-200 responses
});
Two critical points here. First, always return HTTP 200 immediately, even if your downstream processing is async. MTN's webhook engine interprets anything else as a failure and will retry — potentially firing duplicate callbacks. Second, do not trust the callback alone. Before marking an order paid, verify the transaction status by polling the status endpoint and confirming the financialTransactionId is present.
Edge Cases That Break Production Apps
These are the issues that only surface after you go live:
1. Currency Mismatch
The sandbox accepts EUR as a test currency. Production environments in Ghana require GHS, Uganda requires UGX, and so on. Hardcoding EUR from sandbox testing is one of the most common production bugs.
2. Phone Number Format
MoMo expects the MSISDN in international format without the leading +. For Ghana, 0241234567 must be submitted as 233241234567. Build a normalization utility early.
3. Token Expiry Under Load
The Bearer token is valid for one hour. Under production traffic, teams often initialize a single token at startup and never refresh it. Cache the token with a TTL slightly shorter than 3600 seconds and refresh proactively.
4. Duplicate Reference IDs
The X-Reference-Id must be a globally unique UUID per transaction. Reusing a reference ID — even across environments — returns a 409 Conflict. Generate a fresh UUID v4 for every request.
5. Sandbox vs. Production Header Differences
The X-Target-Environment header changes from sandbox to your assigned production environment string (e.g., mtnghana, mtnuganda). This is easy to miss during deployment.
Moving to Production
To go live, you submit a go-live request through the developer portal, receive production credentials, update your base URL and environment headers, and ensure your callback host is publicly reachable over HTTPS. MTN's approval process varies by country and merchant category, so factor in two to four weeks for onboarding.
Why This Matters for Your Project
If you are building a subscription platform, an e-commerce checkout, or any SaaS product targeting users in MTN's markets, Mobile Money is not optional infrastructure — it is the primary payment rail. Getting the integration right, especially around webhook reliability and error handling, directly affects your conversion rate and your users' trust. Abstracting the MoMo logic behind a clean service layer in your Node.js app also makes it straightforward to add Vodafone Cash, AirtelTigo Money, or other local wallets later without rearchitecting your checkout flow. Build it once, build it properly.





