Mobile money moves Africa. In Ghana alone, MTN Mobile Money processed billions of cedis in transactions last year, and yet most payment integration tutorials still point developers toward Stripe or PayPal — tools that require international cards most of your users simply do not have.

If you are building a SaaS product, marketplace, or fintech app for an African audience, MTN MoMo is not optional — it is the payment rail. This guide walks you through a complete integration into a Node.js REST API: sandbox provisioning, authentication, initiating a payment request, and — critically — handling the asynchronous callback flow that catches most developers off guard the first time.


Understanding the MTN MoMo API Architecture

Before writing a single line of code, you need to understand how this API actually works, because it is fundamentally different from synchronous payment APIs.

When you call MTN MoMo's Request to Pay endpoint, the API immediately returns a 202 Accepted response. That does not mean the payment succeeded. It means the request has been queued. The user receives a USSD prompt on their phone, approves or rejects it, and MTN then calls your callback URL with the final status — asynchronously, sometimes seconds later, sometimes minutes.

This async-first design reflects the real-world nature of mobile money: the network is the user's phone, not a browser session. Build your system around this fact from day one.


Step 1 — Sandbox Setup and Credentials

Head to the MTN MoMo Developer Portal and create an account. Subscribe to the Collection product (for receiving payments). You will get a Subscription Key (Ocp-Apim-Subscription-Key).

Next, provision your sandbox credentials. Use the sandbox provisioning API to generate a User ID and API Key:

# 1. Create a sandbox user — replace YOUR_SUBSCRIPTION_KEY
curl -X POST \
  https://sandbox.momodeveloper.mtn.com/v1_0/apiuser \
  -H "X-Reference-Id: $(uuidgen)" \
  -H "Ocp-Apim-Subscription-Key: YOUR_SUBSCRIPTION_KEY" \
  -H "Content-Type: application/json" \
  -d '{"providerCallbackHost": "https://your-api.example.com"}'

# 2. Generate API Key for that user UUID (use the UUID from step 1)
curl -X POST \
  https://sandbox.momodeveloper.mtn.com/v1_0/apiuser/{USER_UUID}/apikey \
  -H "Ocp-Apim-Subscription-Key: YOUR_SUBSCRIPTION_KEY"

Store the returned USER_UUID and apiKey securely in your environment variables. You now have everything needed to authenticate.


Step 2 — Generating an Access Token

MTN MoMo uses OAuth 2.0. You exchange your User ID and API Key for a short-lived Bearer token before every set of API calls (tokens expire in one hour).

// services/momoAuth.js
const axios = require('axios');

const MOMO_BASE_URL = process.env.MOMO_BASE_URL; // sandbox or production
const SUBSCRIPTION_KEY = process.env.MOMO_SUBSCRIPTION_KEY;
const USER_ID = process.env.MOMO_USER_ID;
const API_KEY = process.env.MOMO_API_KEY;

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

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

  return response.data.access_token;
}

module.exports = { getAccessToken };

In production, cache this token in Redis with a TTL of 55 minutes rather than fetching a new one on every request. The savings in latency add up quickly at scale.


Step 3 — Initiating a Request to Pay

With a valid token, you can now ask a subscriber to pay. Each request requires a unique UUID as a reference ID — you will use this same ID later to query transaction status or match the incoming callback.

// services/momoCollections.js
const axios = require('axios');
const { v4: uuidv4 } = require('uuid');
const { getAccessToken } = require('./momoAuth');

async function requestToPay({ amount, currency, phoneNumber, description, externalId }) {
  const token = await getAccessToken();
  const referenceId = uuidv4();

  await axios.post(
    `${process.env.MOMO_BASE_URL}/collection/v1_0/requesttopay`,
    {
      amount: String(amount),
      currency,                        // 'EUR' in sandbox, 'GHS' in production (Ghana)
      externalId,                      // your internal order/transaction ID
      payer: {
        partyIdType: 'MSISDN',
        partyId: phoneNumber,          // e.g. '233XXXXXXXXX'
      },
      payerMessage: description,
      payeeNote: description,
    },
    {
      headers: {
        Authorization: `Bearer ${token}`,
        'X-Reference-Id': referenceId,
        'X-Target-Environment': process.env.MOMO_ENVIRONMENT, // 'sandbox' or 'production'
        'Ocp-Apim-Subscription-Key': process.env.MOMO_SUBSCRIPTION_KEY,
        'Content-Type': 'application/json',
      },
    }
  );

  return referenceId; // store this — you need it to check status or match the callback
}

module.exports = { requestToPay };

A 202 Accepted response means success at this stage. Anything in the 4xx range means a bad request — validate your phone number format and currency code first.


Step 4 — Handling the Async Callback

This is where most first-time integrations break. The callback arrives at your providerCallbackHost URL as a POST request from MTN's servers. You need to:

  1. Expose a publicly accessible HTTPS endpoint (use ngrok locally for testing).
  2. Parse the payload and extract the referenceId and status.
  3. Respond with 200 OK immediately — before doing any heavy processing.
  4. Queue the actual business logic (order fulfillment, email notification, etc.) into a background worker.

What the callback payload looks like

{
  "financialTransactionId": "363440463",
  "externalId": "your-internal-order-id",
  "amount": "50",
  "currency": "GHS",
  "payer": { "partyIdType": "MSISDN", "partyId": "233XXXXXXXXX" },
  "payerMessage": "Payment for Order #1042",
  "payeeNote": "Payment for Order #1042",
  "status": "SUCCESSFUL"
}

The status field will be SUCCESSFUL, FAILED, or PENDING. Never fulfill an order on a PENDING status.

Verifying the callback is genuine

MTN does not currently sign callbacks with an HMAC signature the way Stripe does. Your best mitigation strategies are:

  • Whitelist MTN's IP ranges at your load balancer or firewall level.
  • Always cross-verify by calling the GET /collection/v1_0/requesttopay/{referenceId} endpoint to confirm status server-to-server before updating your database.
  • Treat incoming callbacks as notifications only — the GET verification is your source of truth.

Step 5 — Polling as a Fallback

Callbacks can occasionally fail due to network issues. Implement a polling job that runs every 30 seconds for any payment stuck in PENDING state for more than two minutes. Query the status endpoint directly using the stored referenceId and update your records accordingly. A simple cron job with node-cron or a BullMQ worker handles this cleanly.


Going to Production

The switch from sandbox to production involves three key changes:

  • Update base URLs to the production MTN MoMo endpoints for your country.
  • Replace the currency codeGHS for Ghana, UGX for Uganda, XAF for Cameroon, etc.
  • Complete KYB (Know Your Business) verification through your MTN account manager. This is a manual process — budget two to four weeks.

Test every error scenario in sandbox before going live: insufficient funds, wrong PIN, timed-out USSD sessions. MTN exposes test MSISDN numbers that simulate each failure mode.


Why This Matters for Your Project

If you are building a SaaS product for African markets, payment UX is a retention variable, not just a checkout detail. An integration that handles async callbacks gracefully, recovers from dropped webhooks, and confirms transactions server-side before fulfilling orders will dramatically reduce support tickets and disputed payments. The MTN MoMo API is capable and well-documented — the complexity is in the async flow, and now you know exactly how to tame it.