Mobile money is not a convenience feature in West Africa — it is the payment rail. If your SaaS product, marketplace, or fintech app needs to collect or disburse money in Ghana, Uganda, Cameroon, or Ivory Coast, you will eventually sit across from the MTN MoMo API. The documentation is sparse, the sandbox behaves differently from production, and the error messages are, charitably, cryptic. This guide is the one you wish existed the first time.

Understanding the MoMo API Structure

MTN's MoMo API is built on the Mojaloop-adjacent Open API initiative. It exposes three primary products:

  • Collections — charge a customer's MoMo wallet (the one you'll use most)
  • Disbursements — push money out to a wallet (payouts, refunds)
  • Remittances — cross-border transfers

Each product is a separate API with its own base URL, subscription key, and OAuth token. This trips up nearly every developer the first time: one API key does not rule them all.

Step 1: Get Your Sandbox Credentials

Head to the MTN MoMo Developer Portal and create an account. Subscribe to the Collections product (and Disbursements if you need it). You will receive a Primary Key and Secondary Key — these are your Ocp-Apim-Subscription-Key headers, not your OAuth credentials. Many developers confuse the two and hit 401s for hours.

Next, create a sandbox API user and API key using the provisioning endpoint. This step is manual in production (done by MTN), but in the sandbox you call it yourself:

# 1. Create an API User (use any UUID as X-Reference-Id)
curl -X POST https://sandbox.momodeveloper.mtn.com/v1_0/apiuser \
  -H "X-Reference-Id: YOUR_UUID_HERE" \
  -H "Ocp-Apim-Subscription-Key: YOUR_PRIMARY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"providerCallbackHost": "https://your-callback-url.com"}'

# 2. Create the 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_PRIMARY_KEY"

Save the returned apiKey alongside your UUID — together they form the credentials you will Base64-encode for Basic Auth when fetching OAuth tokens.

Step 2: Fetch an Access Token in Node.js

MTN MoMo uses OAuth 2.0 client credentials flow. Tokens expire in 3600 seconds, so cache them server-side rather than fetching one per request.

const axios = require('axios');

const BASE_URL = 'https://sandbox.momodeveloper.mtn.com';
const SUBSCRIPTION_KEY = process.env.MOMO_SUBSCRIPTION_KEY;
const API_USER = process.env.MOMO_API_USER;
const API_KEY = process.env.MOMO_API_KEY;

let cachedToken = null;
let tokenExpiry = 0;

async function getAccessToken() {
  if (cachedToken && Date.now() < tokenExpiry) return cachedToken;

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

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

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

Notice the /collection/token/ path — if you are working with Disbursements, it becomes /disbursement/token/. Every product has its own token endpoint.

Step 3: Initiate a Collection Request

A collection request (requesting payment from a customer) is asynchronous. You POST the request and get back a 202 Accepted. The actual payment result arrives via webhook — or you poll for it.

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

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

  await axios.post(
    `${BASE_URL}/collection/v1_0/requesttopay`,
    {
      amount: String(amount),
      currency,                  // 'EUR' in sandbox, your local ISO code in prod
      externalId: uuidv4(),
      payer: { partyIdType: 'MSISDN', partyId: phone },
      payerMessage: description,
      payeeNote: description,
    },
    {
      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 to poll or match against webhook callbacks
}

Production gotcha: The sandbox forces currency: 'EUR' regardless of the country. In production, use the correct ISO code — GHS for Ghana, UGX for Uganda, XAF for Cameroon. Mixing these up is one of the most common go-live failures.

Step 4: Check Payment Status

Poll the status endpoint using the referenceId from Step 3:

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

  const { data } = 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 data.status; // 'PENDING' | 'SUCCESSFUL' | 'FAILED'
}

For a production system, implement exponential backoff polling (check at 5s, 15s, 45s) and set a final timeout of around 90 seconds. After that, treat the transaction as failed on your side and surface a retry option to the user.

Step 5: Handle Webhooks Properly

If you provided a providerCallbackHost when creating your API user, MTN will POST a callback to your server when the transaction settles. This is the preferred flow for production — polling is a fallback.

Your Express webhook handler should:

  1. Immediately return 200 OK before doing any heavy processing
  2. Verify the X-Reference-Id header matches a pending transaction in your database
  3. Check status === 'SUCCESSFUL' before marking the order as paid
  4. Be idempotent — MTN may fire the webhook more than once
app.post('/webhooks/momo', express.json(), async (req, res) => {
  res.sendStatus(200); // acknowledge fast

  const { referenceId, status, financialTransactionId } = req.body;

  if (status === 'SUCCESSFUL') {
    await db.orders.markPaid({ referenceId, financialTransactionId });
  } else if (status === 'FAILED') {
    await db.orders.markFailed({ referenceId });
  }
});

Store financialTransactionId — this is MTN's internal transaction ID and is what you will need for reconciliation or dispute resolution.

Common Production Pitfalls

  • Target environment header: Forgetting to change X-Target-Environment from sandbox to your production environment name (e.g., mtngh for Ghana) is the single most common go-live bug.
  • Phone number format: Always use the full international format without the + sign — 233244123456, not 0244123456 or +233244123456.
  • Subscription key rotation: MTN's portal allows primary and secondary keys for zero-downtime rotation. Wire your app to support switching via an environment variable, not a code deploy.
  • Rate limits: The sandbox is throttled aggressively. If you are running load tests, you will hit 429s. Test volume scenarios in a staging environment pointed at production credentials with MTN's approval.

Why This Matters for Your Project

Payment integration quality is a direct predictor of conversion rate, especially in markets where users are accustomed to mobile money being instant and reliable. A poorly handled timeout, a missing idempotency check, or a webhook that fires a duplicate fulfillment can cost you customer trust that is very hard to rebuild. Building your MoMo integration with proper token caching, idempotent webhook handlers, and environment-aware configuration from day one is not over-engineering — it is the baseline for any payment system that needs to scale beyond a few hundred transactions a day.