MTN Mobile Money processes billions of dollars annually across Ghana, Nigeria, Uganda, Côte d'Ivoire, and more than a dozen other African markets. If you are building a SaaS product, e-commerce platform, or fintech app for West African users, ignoring MoMo is not a technical choice — it is a business mistake. Yet most payment integration guides default to Stripe, leaving developers to piece together MoMo documentation on their own.

This guide walks you through a production-ready MTN MoMo integration in Node.js, from sandbox credentials to live webhook handling.


Understanding the MoMo API Structure

The MTN MoMo API is organised around three main products:

  • Collections — receive payments from customers (the one you need most)
  • Disbursements — send money to users or merchants
  • Remittances — cross-border transfers

Each product has its own subscription key and base URL. For most apps, you will start with Collections. The API follows REST conventions, uses OAuth 2.0 bearer tokens, and communicates results both synchronously (status codes) and asynchronously (webhooks / callbacks).


Step 1: Set Up Your Sandbox Account

Head to the MTN MoMo Developer Portal and register an account. Once verified:

  1. Subscribe to the Collections product under the API Marketplace.
  2. Copy your Primary Key (this is your Ocp-Apim-Subscription-Key).
  3. Generate sandbox credentials by calling the /v1_0/apiuser endpoint to create an API user, then /v1_0/apiuser/{referenceId}/apikey to get an API key.

The sandbox base URL is:

https://sandbox.momodeveloper.mtn.com

Keep your subscription key, API user ID (a UUID you generate), and API key in environment variables — never in source code.


Step 2: Authenticate and Get an Access Token

MoMo uses OAuth 2.0 client credentials flow. Your API user ID and API key are Base64-encoded as userId:apiKey and sent as a Basic auth header.

const axios = require('axios');
const { Buffer } = require('buffer');
require('dotenv').config();

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';

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

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

  return response.data.access_token;
}

Tokens expire after one hour. Cache the token and refresh it before expiry rather than requesting a new one on every API call — excessive token requests will get your credentials rate-limited in production.


Step 3: Initiate a Payment Request (Request to Pay)

The core Collections endpoint is POST /collection/v1_0/requesttopay. You provide a reference ID (UUID v4), the customer's MSISDN (phone number in international format, e.g. 233XXXXXXXXX for Ghana), and the amount.

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

async function requestPayment({ phoneNumber, amount, currency = 'GHS', note }) {
  const token = await getAccessToken();
  const referenceId = uuidv4();

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

  return referenceId; // store this — you need it to check status
}

A 202 Accepted response means the request was queued, not completed. The actual payment status is delivered asynchronously.


Step 4: Check Payment Status

Poll the status endpoint using the reference ID you stored:

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

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

  return response.data.status; // PENDING | SUCCESSFUL | FAILED
}

Do not poll aggressively. A sensible pattern is to check after 10 seconds, then again at 30 seconds, then rely on your webhook for the final state.


Step 5: Handle Webhooks (Callbacks)

When you submit a payment request, you can include a callbackUrl in the request headers. MTN will POST the transaction result to that URL once the customer approves or declines.

The Callback URL Gotcha

This is where most developers lose hours. MTN's sandbox will not call localhost. You must expose a public URL — use ngrok during development.

Additionally, your callback URL must be whitelisted in the developer portal under your API user's profile before it will receive any traffic in production. Missing this step means your webhook never fires, your payment appears stuck in PENDING, and nothing in the logs tells you why.

A minimal Express webhook handler:

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

  if (status === 'SUCCESSFUL') {
    // update your database, fulfil the order
  } else {
    // log failure, notify user
  }

  res.sendStatus(200); // always acknowledge
});

Always respond with 200 OK immediately. If your handler throws before responding, MTN may retry — or silently drop the callback.


Common Gotchas Summary

  • Currency mismatch: Use GHS for Ghana, UGX for Uganda, XOF for Francophone markets. Sending the wrong currency code returns a cryptic 400 error.
  • Phone number format: Always use the international format without the + prefix (233XXXXXXXXX, not +233... or 0...).
  • UUID uniqueness: The X-Reference-Id must be globally unique per request. Reusing one returns a 409 Conflict.
  • Sandbox test numbers: MTN provides specific MSISDN values in the sandbox that simulate success, failure, and timeout scenarios. Use them — do not use real numbers in sandbox mode.
  • Production environment header: Switch X-Target-Environment from sandbox to mtncameroon, mtnghana, mtnuganda, etc., depending on your market.

Moving to Production

When you are ready to go live, submit your app for review on the developer portal. You will receive production credentials, a new base URL per market, and access to live callback registration. Expect the review process to take several business days — factor this into your launch timeline.


Why This Matters for Your Project

Mobile money is not a niche payment method in West Africa — it is the default. Building a checkout flow that only supports card payments will exclude a significant portion of your potential user base before they even see your product. Integrating MTN MoMo early, getting the sandbox configuration right, and handling asynchronous payment states robustly will give your app a production-grade payments layer that meets users where they already transact every day.