What you'll build

A Node.js/Express backend that collects payments via MTN Mobile Money. MTN MoMo is the leading mobile money platform across West and Central Africa — Ghana, Uganda, Rwanda, Cameroon, Cote d'Ivoire, and Zambia. Unlike card payment APIs, there is no official Node.js SDK. You make direct HTTP calls to MTN's API, which prompts the customer on their phone to approve the transaction with their MoMo PIN.

By the end of this tutorial:

Prerequisites

How MTN MoMo Collections works

  1. Provision an API user and generate an API key (one-time setup, done programmatically)
  2. Authenticate by exchanging the API user/key for a Bearer token (valid for 1 hour)
  3. Request to pay — your server sends the payment request; MTN prompts the customer's phone
  4. Poll for status — the customer approves or rejects; you poll until the status is final

Get your API key

MTN MoMo uses a 3-step provisioning process. Start in the sandbox:

  1. Go to momodeveloper.mtn.com and create an account
  2. Subscribe to the Collections product — this gives you a Primary Key (Ocp-Apim-Subscription-Key)
  3. The API user and API key are created programmatically (covered in the next section)

Add the subscription key to your .env:

MOMO_SUBSCRIPTION_KEY=your_primary_key_from_dashboard
MOMO_BASE_URL=https://sandbox.momodeveloper.mtn.com
MOMO_TARGET_ENVIRONMENT=sandbox
MOMO_CURRENCY=EUR
PORT=3000

The sandbox only supports EUR as the currency, regardless of the target country. In production, use the real currency for the market: GHS for Ghana, UGX for Uganda, RWF for Rwanda, XOF for Cote d'Ivoire, ZMW for Zambia. The production base URL is https://proxy.momoapi.mtn.com.

Initialize the project

mkdir momo-app && cd momo-app
npm init -y
npm install express dotenv axios uuid
npm install -D typescript @types/express @types/node @types/uuid tsx
npx tsc --init

Add to package.json scripts:

"dev": "tsx watch src/index.ts"

Build the payment endpoint

The MTN MoMo API requires no official SDK — you make direct HTTP calls. The authentication has three steps: create an API user, generate an API key for that user, then exchange them for a Bearer token.

Understanding the 3-step auth

Unlike most payment APIs where you get a key from a dashboard, MTN MoMo requires you to programmatically create your credentials:

  1. Create API User — POST a UUID to /v1_0/apiuser. This registers your application.
  2. Create API Key — POST to /v1_0/apiuser/{userId}/apikey. MTN generates a secret key for your user.
  3. Get Bearer Token — POST to /collection/token/ with Basic auth (userId:apiKey). Returns a token valid for 3600 seconds.

In the sandbox, you provision a new API user each time. In production, you receive fixed credentials from the MTN partner portal.

BlocWeave prompt

Create an MTN MoMo Collections integration with Express. 1. src/momo.ts — export these functions: - provisionApiUser(): Generate a UUID v4 as the userId. POST to ${MOMO_BASE_URL}/v1_0/apiuser with headers: { "X-Reference-Id": userId, "Ocp-Apim-Subscription-Key": MOMO_SUBSCRIPTION_KEY, "Content-Type": "application/json" } and body: { providerCallbackHost: "https://example.com" }. Expect 201. Then POST to ${MOMO_BASE_URL}/v1_0/apiuser/${userId}/apikey with header { "Ocp-Apim-Subscription-Key": MOMO_SUBSCRIPTION_KEY }. Expect 201 with { apiKey }. Return { userId, apiKey }. - getAccessToken(userId: string, apiKey: string): POST to ${MOMO_BASE_URL}/collection/token/ with Authorization header Basic ${Buffer.from(userId + ":" + apiKey).toString("base64")} and header { "Ocp-Apim-Subscription-Key": MOMO_SUBSCRIPTION_KEY }. Returns { access_token, token_type, expires_in }. Cache the token in a module-level variable with its expiry and only re-fetch when expired. - requestToPay(params: { amount: string, currency: string, phone: string, externalId: string, payerMessage: string, payeeNote: string }, accessToken: string): Generate a UUID v4 as referenceId. POST to ${MOMO_BASE_URL}/collection/v1_0/requesttopay with headers: { Authorization: "Bearer " + accessToken, "X-Reference-Id": referenceId, "X-Target-Environment": MOMO_TARGET_ENVIRONMENT, "Ocp-Apim-Subscription-Key": MOMO_SUBSCRIPTION_KEY, "Content-Type": "application/json" } and body: { amount: params.amount, currency: params.currency, externalId: params.externalId, payer: { partyIdType: "MSISDN", partyId: params.phone }, payerMessage: params.payerMessage, payeeNote: params.payeeNote }. Expect 202 Accepted (no response body). Return referenceId. - getPaymentStatus(referenceId: string, accessToken: string): GET ${MOMO_BASE_URL}/collection/v1_0/requesttopay/${referenceId} with headers: { Authorization: "Bearer " + accessToken, "X-Target-Environment": MOMO_TARGET_ENVIRONMENT, "Ocp-Apim-Subscription-Key": MOMO_SUBSCRIPTION_KEY }. Return the response body which has { amount, currency, financialTransactionId, externalId, payer, payerMessage, payeeNote, status, reason }. 2. src/index.ts — Express app with: - On startup, call provisionApiUser() to get userId and apiKey. Store them in module-level variables. Then call getAccessToken(userId, apiKey) to warm the token cache. Log "MoMo API provisioned" on success. - An in-memory Map<string, object> called payments to track payment status by referenceId. - POST /pay: validate body { phone: string, amount: string, currency: string }. Validate that currency is one of ["GHS", "UGX", "RWF", "XOF", "ZMW", "EUR"]. Validate phone matches /^\d{10,15}$/ (MSISDN format without +). If phone starts with "+", strip it. Get access token (uses cache). Generate externalId as "MOMO-" + Date.now(). Call requestToPay, store the referenceId in the payments map with status "pending", amount, currency, phone. Return 202 { referenceId, message: "Payment request sent. The customer will receive a prompt on their phone." }. - GET /payments/:id: get access token, call getPaymentStatus with the referenceId. Update the local payments map with the latest status from MTN. Return the full payment status object. Return 404 if the referenceId is not in the payments map. Use express.json() globally. Add error handling middleware. Load env vars with dotenv. Use the uuid package for UUID v4 generation.

Handle the webhook

MTN MoMo supports an optional callback URL, but the sandbox does not reliably deliver callbacks. The recommended approach is to poll for payment status. For production, you can set up a callback:

BlocWeave prompt

Add an MTN MoMo callback handler and a polling utility to the Express app in src/index.ts. 1. POST /momo/callback: - MTN sends a PUT (not POST) to the callback URL, but we mount both PUT and POST on the same path for flexibility. - The body contains { financialTransactionId, externalId, amount, currency, payer, payerMessage, payeeNote, status }. - Find the payment in the payments map by iterating and matching the externalId. - Update the payment record with the status from the callback, plus financialTransactionId and a completedAt timestamp. - Return 200. - Mount as both app.put("/momo/callback", ...) and app.post("/momo/callback", ...). 2. Add a helper function pollPaymentStatus(referenceId: string, maxAttempts = 10, intervalMs = 3000): Promise to src/momo.ts: - Get access token. - Loop up to maxAttempts times: call getPaymentStatus. If status is "SUCCESSFUL" or "FAILED", return the result. Otherwise wait intervalMs milliseconds and retry. - If maxAttempts exhausted, throw an Error "Payment status polling timed out". 3. Add GET /payments/:id/wait to src/index.ts: - Call pollPaymentStatus(referenceId) and return the final status. This is a long-polling endpoint for clients that want to wait for the result. - Set a response timeout of 60 seconds.

Test it

Start the server:

npm run dev

The server will provision an API user on startup. Trigger a test payment using the sandbox test number:

# Sandbox test phone number for MTN MoMo
curl -X POST http://localhost:3000/pay \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "233540000000",
    "amount": "50",
    "currency": "EUR"
  }'

Note the referenceId from the response, then poll for the status:

curl http://localhost:3000/payments/REFERENCE_ID

Or use the long-polling endpoint to wait for the final result:

curl http://localhost:3000/payments/REFERENCE_ID/wait

In the sandbox, the payment will automatically succeed after a few seconds. The status transitions from PENDING to SUCCESSFUL.

Payment status values

Status Meaning
PENDING Payment prompt sent, waiting for customer
SUCCESSFUL Customer approved, money debited
FAILED Customer rejected or error occurred

The reason field in the response provides details when a payment fails. Common reasons include:

  • PAYER_NOT_FOUND — the phone number is not registered for MoMo
  • NOT_ENOUGH_FUNDS — insufficient balance
  • PAYEE_NOT_ALLOWED_TO_RECEIVE — your merchant account cannot receive payments
  • INTERNAL_PROCESSING_ERROR — retry the request

Phone number formats by country

Country Format Example
Ghana 233 + 9 digits 233540000000
Uganda 256 + 9 digits 256770000000
Rwanda 250 + 9 digits 250780000000
Cote d'Ivoire 225 + 10 digits 2250700000000
Zambia 260 + 9 digits 260970000000
Cameroon 237 + 9 digits 237670000000

Always use MSISDN format without the leading +. If a customer enters +233540000000 or 0540000000, strip the + or replace the leading 0 with the country code.

Environment variables by target market

In production, MOMO_TARGET_ENVIRONMENT is set to the market identifier, not "sandbox":

Country Target Environment Currency Base URL
Ghana mtnghana GHS proxy.momoapi.mtn.com
Uganda mtnuganda UGX proxy.momoapi.mtn.com
Rwanda mtnrwanda RWF proxy.momoapi.mtn.com
Cote d'Ivoire mtnivorycoast XOF proxy.momoapi.mtn.com
Zambia mtnzambia ZMW proxy.momoapi.mtn.com
Cameroon mtncameroon XAF proxy.momoapi.mtn.com

Production checklist: (1) Switch MOMO_BASE_URL to https://proxy.momoapi.mtn.com and MOMO_TARGET_ENVIRONMENT to the country code. (2) Use real API credentials from the MTN MoMo partner portal instead of sandbox provisioning. (3) Store payments in a real database. (4) Set the providerCallbackHost to your actual domain. (5) The amount field is a string in the MTN API — always pass it as a string, not a number. (6) Add retry logic for network failures — mobile money APIs can be intermittently slow. (7) Bearer tokens expire after 3600 seconds — always check expiry before making API calls.

Project structure after this tutorial

src/
  momo.ts           # API provisioning, auth, request-to-pay, status polling
  index.ts          # Express app, routes, callback handler
.env                # MOMO_SUBSCRIPTION_KEY, MOMO_BASE_URL, etc.
package.json