MTN Mobile Money processes billions of dollars annually across more than 17 African markets. If you are building a SaaS product, e-commerce platform, or any customer-facing web app targeting users in Ghana, Uganda, Ivory Coast, Cameroon, or Zambia, ignoring MoMo is not a product decision — it is a market exclusion decision.

This guide skips the sales pitch and goes straight to the integration: sandbox setup, OAuth2 authentication, initiating a collection request, and handling the asynchronous callback that tells you whether the customer actually paid.


Understanding the MoMo API Architecture

MTN's MoMo API is organized into distinct products. The one you care about first is Collections — it lets your app request a payment from a customer's MoMo wallet. The other products (Disbursements, Remittances) come later when you need to pay out.

The API is REST-based and uses OAuth2 Bearer tokens for authentication. Every API call lives under a subscription key tied to your developer account, and tokens are scoped to a specific product. One important mental model to internalize early: payments are asynchronous. You initiate a request, the customer gets a USSD prompt on their phone, approves it, and MTN's server calls your webhook. Your backend must be designed around this flow, not a synchronous request-response.


Step 1 — Create a Sandbox Account and Get Your Keys

Head to momodeveloper.mtn.com and register. Once confirmed:

  1. Navigate to Products and subscribe to the Collection API.
  2. Copy your Primary Subscription Key from your profile. You will send this as the Ocp-Apim-Subscription-Key header on every request.
  3. Use the sandbox base URL: https://sandbox.momodeveloper.mtn.com

Next, provision a sandbox user and API key. This is a two-step process unique to MoMo — unlike some APIs that give you a single static secret, MoMo requires you to create a sandbox user identity first.

# Step 1: Create a sandbox API user
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://yourdomain.com" }'

# Step 2: 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 UUID (which becomes your apiUserId). You now have everything needed to authenticate.


Step 2 — Obtain a Bearer Token

MoMo uses Basic Auth to exchange your credentials for a short-lived Bearer token. Encode apiUserId:apiKey in Base64 and POST to the token endpoint.

const axios = require("axios");

async function getMoMoToken(subscriptionKey, apiUserId, apiKey) {
  const credentials = Buffer.from(`${apiUserId}:${apiKey}`).toString("base64");

  const response = await axios.post(
    "https://sandbox.momodeveloper.mtn.com/collection/token/",
    {},
    {
      headers: {
        Authorization: `Basic ${credentials}`,
        "Ocp-Apim-Subscription-Key": subscriptionKey,
        "Content-Type": "application/json",
      },
    }
  );

  return response.data.access_token; // valid for 3600 seconds
}

Tokens expire after one hour. Cache them server-side and refresh proactively — do not request a new token on every payment initiation, as this adds unnecessary latency and hammers the auth endpoint.


Step 3 — Initiate a Collection Request

With a valid Bearer token, you can request a payment from a customer. The key field here is externalId — this is your internal reference (an order ID, invoice number, etc.) that you will use to reconcile the payment later.

async function requestPayment({ token, subscriptionKey, amount, currency, phoneNumber, externalId, description }) {
  const referenceId = crypto.randomUUID(); // track THIS on your end too

  await axios.post(
    "https://sandbox.momodeveloper.mtn.com/collection/v1_0/requesttopay",
    {
      amount: String(amount),
      currency,           // "EUR" in sandbox; your local currency in production
      externalId,
      payer: {
        partyIdType: "MSISDN",
        partyId: phoneNumber, // format: 233XXXXXXXXX (no +)
      },
      payerMessage: description,
      payeeNote: description,
    },
    {
      headers: {
        Authorization: `Bearer ${token}`,
        "X-Reference-Id": referenceId,
        "X-Target-Environment": "sandbox",
        "Ocp-Apim-Subscription-Key": subscriptionKey,
        "Content-Type": "application/json",
      },
    }
  );

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

A successful initiation returns HTTP 202 Accepted — not a payment confirmation. The customer now sees a USSD prompt. This is where most first-time integrations break: developers treat 202 as "paid" and proceed. It is not. It means "request received."


Step 4 — Handle Asynchronous Payment Confirmation

You have two options for confirmation: polling or webhooks. Webhooks are production-grade; polling is acceptable for low-volume or background job scenarios.

Option A: Polling the Status Endpoint

async function checkPaymentStatus(referenceId, token, subscriptionKey) {
  const response = await axios.get(
    `https://sandbox.momodeveloper.mtn.com/collection/v1_0/requesttopay/${referenceId}`,
    {
      headers: {
        Authorization: `Bearer ${token}`,
        "X-Target-Environment": "sandbox",
        "Ocp-Apim-Subscription-Key": subscriptionKey,
      },
    }
  );
  return response.data.status; // "PENDING" | "SUCCESSFUL" | "FAILED"
}

Poll with exponential backoff — start at 5 seconds, double each attempt, cap at 60 seconds, and give up after 10 minutes. Mark unresolved transactions as EXPIRED and surface them for manual review.

Option B: Webhook Callbacks

Register your providerCallbackHost during sandbox user creation (Step 1). MTN will POST a payload to https://yourdomain.com/webhooks/momo when the payment status changes. Validate that the referenceId in the payload matches an open transaction in your database before updating its status. Always return HTTP 200 quickly — defer any heavy processing to a background queue.


Common Pitfalls to Avoid

  • Currency mismatch: Sandbox forces EUR. Production uses your market's local currency (GHS for Ghana, UGX for Uganda, etc.). Parameterize this from day one.
  • Phone number format: Strip the leading + and ensure the country code is included. 233241234567 not +233241234567 and definitely not 0241234567.
  • Idempotency: The X-Reference-Id header must be a unique UUID per request. Reusing it will cause unpredictable behavior.
  • Token caching: Failing to cache tokens will throttle your integration under load.
  • Missing error handling on FAILED status: Always distinguish between a network failure on your side and a customer-declined payment. They require different UX and retry logic.

Moving to Production

When you are ready to go live, submit your app through the MoMo Developer Portal for approval. You will receive production credentials, a live base URL, and your target environment changes from "sandbox" to your market identifier (e.g., "mtncameroon", "mtnghana"). The code structure stays identical — only configuration changes, which is exactly why environment-based config matters from the start.


Why This Matters for Your Project

Payment integration is where most African SaaS products either capture or lose their addressable market. MTN MoMo's reach into unbanked and underbanked populations means that card-only checkout is a conversion killer in most sub-Saharan markets. Building MoMo support alongside Paystack or Flutterwave is not redundancy — it is coverage. If you are architecting a multi-tenant platform or marketplace, designing your payment layer to be provider-agnostic from the start will save you a painful refactor the moment your next client is in Kampala, not Accra.