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:
- Your server completes MTN MoMo's 3-step authentication flow (API user, API key, Bearer token)
POST /paysends a request-to-pay prompt to the customer's phoneGET /payments/:idpolls MTN for the transaction statusGET /payments/:id/waitlong-polls until the payment succeeds or fails- Phone numbers are validated in MSISDN format (no +, country code required)
- Supports GHS, UGX, RWF, XOF, and ZMW currencies
Prerequisites
- Node.js 18+ installed
- A MTN MoMo Developer account (free)
- BlocWeave installed in VS Code
How MTN MoMo Collections works
- Provision an API user and generate an API key (one-time setup, done programmatically)
- Authenticate by exchanging the API user/key for a Bearer token (valid for 1 hour)
- Request to pay — your server sends the payment request; MTN prompts the customer's phone
- 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:
- Go to momodeveloper.mtn.com and create an account
- Subscribe to the Collections product — this gives you a Primary Key (Ocp-Apim-Subscription-Key)
- 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:
- Create API User — POST a UUID to
/v1_0/apiuser. This registers your application. - Create API Key — POST to
/v1_0/apiuser/{userId}/apikey. MTN generates a secret key for your user. - 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.
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:
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
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 MoMoNOT_ENOUGH_FUNDS— insufficient balancePAYEE_NOT_ALLOWED_TO_RECEIVE— your merchant account cannot receive paymentsINTERNAL_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
BlocWeave