Integrating MTN MoMo Into Your Node.js App: A Step-by-Step Guide

Paystack works beautifully for card payments, but the moment your SaaS product needs to serve a trader in Kumasi, a freelancer in Kampala, or a small business owner in Kigali, mobile money stops being optional. MTN Mobile Money — MoMo — processes billions of dollars annually across 17 African markets. If your Node.js app does not speak MoMo, you are leaving real revenue on the table.

This guide covers the MTN MoMo API end-to-end: sandbox provisioning, authentication, initiating a collection request, handling webhooks, and the silent failure modes that waste hours for first-time integrators.


How the MTN MoMo API Is Structured

MTN exposes its MoMo services through the MoMo Developer Portal (momodeveloper.mtn.com). The API is split into distinct products:

  • Collections — request payment from a customer's MoMo wallet
  • Disbursements — send money out (payouts, refunds)
  • Remittances — cross-border transfers

For most SaaS and e-commerce use cases, you will start with Collections. Each product has its own subscription key, base URL, and token lifecycle. This is different from a single-API-key model like Stripe, and it catches many developers off-guard.


Step 1: Sandbox Setup

  1. Register at momodeveloper.mtn.com and create an account.
  2. Subscribe to the Collections product under the sandbox environment. You will receive a Subscription Key (Ocp-Apim-Subscription-Key).
  3. Use the portal's built-in tool — or a curl call — to create an API User and generate an API Key. These two credentials, combined with your subscription key, are the auth trifecta for every sandbox request.
# Create an API User (replace YOUR_SUB_KEY and YOUR_UUID)
curl -X POST https://sandbox.momodeveloper.mtn.com/v1_0/apiuser \
  -H "X-Reference-Id: YOUR_UUID" \
  -H "Ocp-Apim-Subscription-Key: YOUR_SUB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"providerCallbackHost": "https://your-app.com"}'

# Generate the API Key for that user
curl -X POST https://sandbox.momodeveloper.mtn.com/v1_0/apiuser/YOUR_UUID/apikey \
  -H "Ocp-Apim-Subscription-Key: YOUR_SUB_KEY"

Store the returned apiKey securely. You will not see it again.


Step 2: Obtaining a Bearer Token in Node.js

The MoMo API uses OAuth 2.0 client credentials. Your API User ID and API Key are Base64-encoded to form the Basic Auth header, which you exchange for a short-lived Bearer token (valid for one hour).

const axios = require("axios");

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

async function getMoMoToken() {
  const credentials = Buffer.from(`${API_USER}:${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;
}

Cache this token for its expiry window. Fetching a new token on every transaction is a latency and rate-limit trap.


Step 3: Initiating a Collection Request

A collection request sends a payment prompt to the customer's MoMo-registered phone number. The request is asynchronous — you get a 202 Accepted immediately, and the actual outcome arrives later via webhook or polling.

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

async function requestPayment({ phoneNumber, amount, currency = "GHS", note }) {
  const token = await getMoMoToken();
  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 or match webhooks
}

The X-Reference-Id is your transaction fingerprint. Persist it to your database immediately after the 202 response.


Step 4: Webhook Handling

If you provided a providerCallbackHost during API User creation, MTN will POST a callback to your server when the transaction reaches a terminal state (SUCCESSFUL or FAILED).

A minimal Express webhook handler looks like this:

app.post("/momo/webhook", express.json(), (req, res) => {
  const { referenceId, status, financialTransactionId } = req.body;

  if (status === "SUCCESSFUL") {
    // Mark order paid in your DB using referenceId
  } else {
    // Handle failure — notify user, release reserved inventory, etc.
  }

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

Critical: MTN does not sign webhooks with a secret in the same way Stripe does. In production, validate the referenceId against your own database before acting on any payload — reject anything you did not initiate.


Step 5: Polling as a Fallback

Webhooks in the sandbox are unreliable by design, and in production, network conditions across African markets mean callbacks occasionally go missing. Always implement a polling fallback.

async function checkPaymentStatus(referenceId) {
  const token = await getMoMoToken();
  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"
}

A job queue (Bull, BullMQ, or even a simple cron) that polls every 30 seconds for up to 5 minutes handles the vast majority of delayed confirmations gracefully.


Error States That Trip Up First-Timers

  • PAYER_NOT_FOUND — The MSISDN does not have an active MoMo account. Validate phone numbers client-side before submission.
  • NOT_ENOUGH_FUNDS — Self-explanatory, but you still need to surface this clearly in your UI and offer a retry path.
  • APPROVAL_REJECTED — The user dismissed the prompt. Treat this as a soft failure; do not mark the order as permanently cancelled on first rejection.
  • EXPIRED — The customer did not respond within the timeout window (usually 60–120 seconds in production). Your polling loop must account for this terminal state.
  • INTERNAL_PROCESSING_ERROR — MTN-side issue. Log the referenceId, wait, and retry. Never silently swallow this.

Moving to Production

The switch from sandbox to production involves three changes: a new subscription key (acquired through MTN's onboarding process for your country), a new API User provisioned under the live environment, and updating X-Target-Environment from "sandbox" to the appropriate production value (e.g., "mtnghana"). Currency codes also change — sandbox accepts "EUR" universally, but production requires the local currency (e.g., "GHS" for Ghana).


Why This Matters for Your Project

If you are building a SaaS product, marketplace, or consumer app targeting African users, mobile money is not a nice-to-have — it is infrastructure. MTN MoMo alone gives you reach into Ghana, Uganda, Côte d'Ivoire, Cameroon, and beyond. Getting the integration right from the start — idempotent reference IDs, webhook-plus-polling redundancy, and proper error surfacing — means your payment layer is as robust as the rest of your stack, and your customers' money moves with the reliability they expect.