Integrating MTN MoMo Into Your Node.js App: A Complete Guide

If your SaaS product targets users in Ghana, Uganda, Côte d'Ivoire, or anywhere else MTN operates, Stripe is not your primary payment rail — Mobile Money is. Over 60% of adults in Sub-Saharan Africa remain unbanked, yet a significant majority hold active mobile money accounts. For product teams building in this market, MTN MoMo is not a "nice to have." It is the checkout.

This guide skips the theory and gets you from zero to a working Node.js integration, including sandbox credentials, request/collection flows, webhook handling, and the production edge cases nobody warns you about.


Understanding the MoMo API Structure

MTN's Mobile Money API (hosted on the Momo Developer portal) is split into distinct products:

  • Collections — charge a customer (pull payment from their wallet)
  • Disbursements — send money to a customer or vendor
  • Remittances — cross-border transfers

For most SaaS billing and e-commerce use cases, you will work primarily with Collections. Each product has its own subscription key, its own base URL path, and its own token scope. Treat them as separate APIs that share an authentication pattern.


Step 1: Sandbox Setup

Head to momodeveloper.mtn.com and create an account. Once verified:

  1. Subscribe to the Collections product (and Disbursements if needed).
  2. Copy your Primary Key — this is your Ocp-Apim-Subscription-Key header value.
  3. Generate a User ID and API Key via the sandbox provisioning endpoint (MTN does not give you these directly — you create them programmatically).
# Create a sandbox user
curl -X POST https://sandbox.momodeveloper.mtn.com/v1_0/apiuser \
  -H "X-Reference-Id: <your-uuid-v4>" \
  -H "Ocp-Apim-Subscription-Key: <your-subscription-key>" \
  -H "Content-Type: application/json" \
  -d '{ "providerCallbackHost": "https://yourapp.com" }'

# Then fetch the API key for that user
curl -X POST https://sandbox.momodeveloper.mtn.com/v1_0/apiuser/<your-uuid-v4>/apikey \
  -H "Ocp-Apim-Subscription-Key: <your-subscription-key>"

Store the X-Reference-Id you generated (your User ID) and the apiKey from the second response. You will need both to authenticate.


Step 2: Generating a Bearer Token in Node.js

Every API call is authenticated with a short-lived OAuth 2.0 Bearer token. Tokens expire in 3600 seconds, so cache them — do not fetch a new one per request.

const axios = require('axios');

const SUBSCRIPTION_KEY = process.env.MOMO_SUBSCRIPTION_KEY;
const USER_ID = process.env.MOMO_USER_ID;
const API_KEY = process.env.MOMO_API_KEY;
const BASE_URL = 'https://sandbox.momodeveloper.mtn.com/collection';

let cachedToken = null;
let tokenExpiresAt = 0;

async function getMoMoToken() {
  if (cachedToken && Date.now() < tokenExpiresAt) return cachedToken;

  const credentials = Buffer.from(`${USER_ID}:${API_KEY}`).toString('base64');

  const response = await axios.post(`${BASE_URL}/token/`, null, {
    headers: {
      Authorization: `Basic ${credentials}`,
      'Ocp-Apim-Subscription-Key': SUBSCRIPTION_KEY,
    },
  });

  cachedToken = response.data.access_token;
  tokenExpiresAt = Date.now() + (response.data.expires_in - 60) * 1000; // 60s buffer
  return cachedToken;
}

Step 3: Initiating a Collection Request

A collection triggers an STK-style push prompt on the customer's phone. You supply the amount, currency, phone number, and a unique reference ID.

const { v4: uuidv4 } = require('uuid');

async function requestPayment({ amount, currency, phoneNumber, description }) {
  const token = await getMoMoToken();
  const referenceId = uuidv4();

  await axios.post(
    `${BASE_URL}/v1_0/requesttopay`,
    {
      amount: String(amount),
      currency,           // "EUR" in sandbox; "GHS", "UGX", etc. in production
      externalId: uuidv4(),
      payer: { partyIdType: 'MSISDN', partyId: phoneNumber },
      payerMessage: description,
      payeeNote: description,
    },
    {
      headers: {
        Authorization: `Bearer ${token}`,
        'X-Reference-Id': referenceId,
        'X-Target-Environment': 'sandbox', // 'production' when live
        'Ocp-Apim-Subscription-Key': SUBSCRIPTION_KEY,
        'Content-Type': 'application/json',
      },
    }
  );

  return referenceId; // Store this — you'll use it to check status
}

A 202 Accepted response means the request is queued — not that payment succeeded. This is a critical distinction.


Step 4: Checking Transaction Status

Poll the status endpoint using the referenceId you generated. In production, rely on webhooks instead of polling where possible, but always implement a status-check fallback.

async function getPaymentStatus(referenceId) {
  const token = await getMoMoToken();

  const response = await axios.get(
    `${BASE_URL}/v1_0/requesttopay/${referenceId}`,
    {
      headers: {
        Authorization: `Bearer ${token}`,
        'X-Target-Environment': 'sandbox',
        'Ocp-Apim-Subscription-Key': SUBSCRIPTION_KEY,
      },
    }
  );

  return response.data.status; // 'PENDING', 'SUCCESSFUL', or 'FAILED'
}

Step 5: Handling Webhooks

Set your providerCallbackHost to your server's public HTTPS URL during sandbox user creation. MTN will POST a callback to {yourHost}/ when a transaction completes.

const express = require('express');
const app = express();
app.use(express.json());

app.post('/momo/callback', (req, res) => {
  const { financialTransactionId, status, externalId } = req.body;

  if (status === 'SUCCESSFUL') {
    // Update your DB, activate subscription, send receipt, etc.
    console.log(`Payment confirmed: ${financialTransactionId}`);
  } else if (status === 'FAILED') {
    // Handle failure — notify user, release held resources
  }

  res.sendStatus(200); // Always acknowledge promptly
});

MTN may retry callbacks if it does not receive a 200. Make your webhook handler idempotent — use the externalId or financialTransactionId as a deduplication key against your database before processing.


Production Gotchas Specific to the West African Market

Switching from sandbox to production is not just a URL swap. Here is what catches most teams off guard:

Currency and Environment Are Tightly Coupled

Sandbox forces EUR as the currency regardless of your target market. Production uses local currencies: GHS for Ghana, UGX for Uganda, XOF for francophone West Africa. Hardcoding currency strings causes silent failures.

Phone Number Formatting

Numbers must be in international format without the + prefix. 0244123456 in Ghana becomes 233244123456. Build a normalisation utility early and test edge cases — users will enter numbers every possible way.

Network Timeouts Are Common

MoMo API response times can spike, especially during peak hours. Set request timeouts generously (15–30 seconds) and implement retry logic with exponential backoff. A 504 from MTN's gateway does not mean your transaction failed — check status before retrying a charge.

Webhook Delivery Is Not Guaranteed

In some markets and network conditions, callbacks arrive late or not at all. Run a background job (e.g., a cron every 5 minutes) that polls PENDING transactions older than 3 minutes for a status update.

Sandbox Test Numbers

MTN provides specific MSISDN values for simulating success and failure in sandbox. Using a real phone number in sandbox will always return PENDING indefinitely.


Why This Matters for Your Project

Mobile money is not a regional quirk — it is the dominant digital payment infrastructure across Africa. SaaS products that treat MoMo as a secondary payment option consistently see lower conversion rates from African users compared to those that make it the primary checkout flow. Getting the integration right — idempotent webhooks, proper number formatting, resilient polling — is the difference between a payment flow that converts and one that erodes trust. If you are building a product for the African market, this is foundational engineering, not a feature.