npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lepresk/momo-api

v2.3.3

Published

MTN Mobile Money and Airtel Money API client for Node.js — collections, disbursements and remittances

Readme

@lepresk/momo-api

npm CI License: MIT

MTN Mobile Money and Airtel Money API client for Node.js — collections, disbursements and remittances.

This is a port of the PHP package lepresk/momo-api to Node.js/TypeScript. See the original article at lepresk.com/blog.

Requirements

  • Node.js 22 or later (uses native fetch and crypto.randomUUID)
  • An MTN MoMo developer account with API credentials (for MTN)
  • An Airtel Money developer account with OAuth2 credentials (for Airtel)

Installation

npm install @lepresk/momo-api
# or
pnpm add @lepresk/momo-api
# or
yarn add @lepresk/momo-api

Getting started

Sandbox setup

Before making live API calls you need to provision a sandbox user and API key via the SandboxApi:

import { MomoApi, ENVIRONMENT_SANDBOX } from '@lepresk/momo-api'
import { generateUUID } from '@lepresk/momo-api'

const momo = MomoApi.create(ENVIRONMENT_SANDBOX)
const sandbox = momo.sandbox('your-subscription-key')

const apiUser = generateUUID()
await sandbox.createApiUser(apiUser, 'https://your-callback-host.com')
const apiKey = await sandbox.createApiKey(apiUser)

console.log('API User:', apiUser)
console.log('API Key:', apiKey)

Collection

The Collection product allows you to request payments from customers.

import { MomoApi, ENVIRONMENT_SANDBOX, Config, PaymentRequest } from '@lepresk/momo-api'

const config = Config.collection(
  'your-subscription-key',
  'your-api-user',
  'your-api-key',
  'https://your-callback-host.com/webhook'
)

const collection = MomoApi.create(ENVIRONMENT_SANDBOX).getCollection(config)

// Request a payment
const request = PaymentRequest.make(
  '100',          // amount
  '0242439784',   // payer phone number (MSISDN)
  'order-123',    // external reference ID
  'EUR',          // currency (optional, defaults to XAF)
  'Payment for order #123',  // payer message (optional)
  'Thank you'                // payee note (optional)
)

const referenceId = await collection.requestToPay(request)
console.log('Payment initiated, reference ID:', referenceId)

// Check payment status
const transaction = await collection.getPaymentStatus(referenceId)

if (transaction.isSuccessful()) {
  console.log('Payment successful:', transaction.getFinancialTransactionId())
} else if (transaction.isPending()) {
  console.log('Payment is still pending')
} else {
  console.log('Payment failed')
}

// Get account balance
const balance = await collection.getBalance()
console.log(`Balance: ${balance.getAvailableBalance()} ${balance.getCurrency()}`)

// Quick pay shorthand
// currency is optional and defaults to XAF
const refId = await collection.quickPay('50', '0242439784', 'quick-order-456', 'EUR')

The sandbox accepts EUR only. Against the sandbox, requestToPay(), quickPay(), deposit(), transfer() and refund() send EUR whatever currency the request was built with (SANDBOX_CURRENCY), so the same XAF code runs in both environments. Outside the sandbox the request currency is sent as is.

Disbursement

The Disbursement product allows you to send money to customers.

import { MomoApi, ENVIRONMENT_SANDBOX, Config, PaymentRequest, TransferRequest, RefundRequest } from '@lepresk/momo-api'

const config = Config.disbursement(
  'your-subscription-key',
  'your-api-user',
  'your-api-key',
  'https://your-callback-host.com/webhook'
)

const disbursement = MomoApi.create(ENVIRONMENT_SANDBOX).getDisbursement(config)

// Deposit funds to a customer
const depositRequest = PaymentRequest.make('200', '0242439784', 'dep-001', 'EUR', 'Deposit', 'Here are your funds')
const depositRefId = await disbursement.deposit(depositRequest)
const deposit = await disbursement.getDepositStatus(depositRefId)

// Transfer funds
const transferRequest = TransferRequest.make('300', '0242439784', 'xfer-001', 'EUR', 'Transfer', 'For you')
const transferRefId = await disbursement.transfer(transferRequest)
const transfer = await disbursement.getTransferStatus(transferRefId)

// Refund a previous payment
const refundRequest = RefundRequest.make('50', 'original-payment-reference-id', 'refund-001', 'EUR')
const refundRefId = await disbursement.refund(refundRequest)
const refund = await disbursement.getRefundStatus(refundRefId)

// Get disbursement account balance
const balance = await disbursement.getBalance()
console.log(`Balance: ${balance.getAvailableBalance()} ${balance.getCurrency()}`)

Airtel Money

Airtel Collection (Receive Payments)

import { AirtelApi } from '@lepresk/momo-api'

const collection = AirtelApi.collection('staging', {
  clientId: 'YOUR_CLIENT_ID',
  clientSecret: 'YOUR_CLIENT_SECRET',
  callbackUri: 'https://your-callback-host.com/webhook',
  country: 'CG',    // optional, defaults to 'CG'
  currency: 'XAF',  // optional, defaults to 'XAF'
})

// Request a payment
const externalId = await collection.requestToPay('5000', '068511358', 'ORDER-001')

// Check payment status
const transaction = await collection.getPaymentStatus(externalId)

if (transaction.isSuccessful()) {
  console.log('Payment received! Airtel Money ID:', transaction.getAirtelMoneyId())
} else if (transaction.isPending()) {
  console.log('Payment pending...')
} else if (transaction.isFailed()) {
  console.log('Payment failed')
}

// Check balance
const balance = await collection.getBalance()
console.log(`Available: ${balance.getAvailableBalance()} ${balance.getCurrency()}`)

Airtel Disbursement (Send Money)

import { AirtelApi } from '@lepresk/momo-api'

const disbursement = AirtelApi.disbursement('production', {
  clientId: 'YOUR_CLIENT_ID',
  clientSecret: 'YOUR_CLIENT_SECRET',
  encryptedPin: 'YOUR_ENCRYPTED_PIN',
  callbackUri: 'https://your-callback-host.com/webhook',
})

// Transfer money
const externalId = await disbursement.transfer('10000', '068511358', 'PAY-001')

// Check transfer status
const transaction = await disbursement.getTransferStatus(externalId)

if (transaction.isSuccessful()) {
  console.log('Transfer completed!')
}

Airtel disbursement PIN

The transfer endpoint takes an RSA-encrypted PIN, never the PIN itself. Encrypt it with the public key Airtel gives you:

import { AirtelApi, encryptAirtelPin } from '@lepresk/momo-api'

const disbursement = AirtelApi.disbursement('production', {
  clientId: process.env.AIRTEL_CLIENT_ID!,
  clientSecret: process.env.AIRTEL_CLIENT_SECRET!,
  encryptedPin: encryptAirtelPin('1234', process.env.AIRTEL_PUBLIC_KEY!),
})

The key is accepted as PEM, base64-encoded PEM, or the bare base64 body (MIIBIj...).

Airtel phone numbers

Airtel expects a national MSISDN. Numbers are normalised for you — passing 242068511358 or 068511358 sends 068511358 either way. cleanPhoneNumber() is exported if you need the same normalisation elsewhere.

Airtel failures

Airtel reports business failures with HTTP 200 and status.success: false, so requestToPay() and transfer() raise a MomoException in that case rather than returning an externalId for a refused request:

import { MomoException } from '@lepresk/momo-api'

try {
  await disbursement.transfer('10000', '068511358', 'PAY-001')
} catch (err) {
  if (err instanceof MomoException) {
    console.error(err.message, err.code)  // "Invalid PIN", "ESB000008"
  }
}

Status codes: TS successful, TF failed, TIP and TI both pending.

Airtel environments

| Mode | URL | Use Case | |------|-----|----------| | staging | https://openapiuat.airtel.cg | Testing | | production | https://openapi.airtel.cg | Production (Congo) |

Static factory methods

MomoApi.collection() and MomoApi.disbursement() build a product from flat credentials, in any environment:

import { MomoApi, ENVIRONMENT_MTN_GHANA } from '@lepresk/momo-api'

const collection = MomoApi.collection({
  environment: ENVIRONMENT_MTN_GHANA,   // optional, defaults to sandbox
  subscriptionKey: 'your-subscription-key',
  apiUser: 'your-api-user',
  apiKey: 'your-api-key',
  callbackUrl: 'https://your-callback-host.com/webhook',  // optional
})

const disbursement = MomoApi.disbursement({
  subscriptionKey: 'your-subscription-key',
  apiUser: 'your-api-user',
  apiKey: 'your-api-key',
})

Missing credentials throw immediately (subscriptionKey is required), and an environment outside the table below throws Unknown environment: '...' rather than silently pointing at production.

They also accept a Config instance, with the environment as a second argument:

const momo = MomoApi.create(ENVIRONMENT_MTN_GHANA)
const collection = momo.getCollection(config)

// equivalent
const same = MomoApi.collection(config, ENVIRONMENT_MTN_GHANA)

Custom HTTP client

Every product uses the global fetch by default. Supply your own fetch-compatible function to add timeouts, retries, proxying or logging:

import { MomoApi, AirtelApi, CollectionApi } from '@lepresk/momo-api'

const withTimeout: typeof fetch = (input, init) =>
  fetch(input, { ...init, signal: AbortSignal.timeout(10_000) })

// with the static factories
const collection = MomoApi.collection({ ...credentials, fetchImpl: withTimeout })

// with a client, inherited by every product it builds
const momo = MomoApi.create(ENVIRONMENT_MTN_GHANA, withTimeout)

// Airtel
const airtel = AirtelApi.create('production', withTimeout)

// or directly, as the last constructor argument
const direct = new CollectionApi(config, baseUrl, environment, withTimeout)

The implementation is scoped to the client you pass it to — nothing is installed process-wide, so two libraries using this package cannot affect each other.

Supported environments

| Constant | Value | |-----------------------------|---------------------| | ENVIRONMENT_MTN_CONGO | mtncongo | | ENVIRONMENT_MTN_UGANDA | mtnuganda | | ENVIRONMENT_MTN_GHANA | mtnghana | | ENVIRONMENT_IVORY_COAST | mtnivorycoast | | ENVIRONMENT_ZAMBIA | mtnzambia | | ENVIRONMENT_CAMEROON | mtncameroon | | ENVIRONMENT_BENIN | mtnbenin | | ENVIRONMENT_SWAZILAND | mtnswaziland | | ENVIRONMENT_GUINEACONAKRY | mtnguineaconakry | | ENVIRONMENT_SOUTHAFRICA | mtnsouthafrica | | ENVIRONMENT_LIBERIA | mtnliberia | | ENVIRONMENT_SANDBOX | sandbox |

Retrying without paying twice

Each write returns the id you poll its status with. By default it is a random UUID, so if the request times out or fails with a 5xx you never learn it: you cannot ask whether the payment went through, and retrying sends a new id that MTN or Airtel treats as a new payment.

Pass your own UUID as the last argument instead, and store it before sending. After an ambiguous failure, query the status with it; retry with the same id only once the status says the first attempt does not exist.

import { v5 as uuidv5 } from 'uuid'

// Derive it from your own key, so the same line always gets the same id
const referenceId = uuidv5(`${batchRef}-${lineNo}`, MY_NAMESPACE_UUID)
await disbursement.transfer(transferRequest, referenceId)
const transfer = await disbursement.getTransferStatus(referenceId)

// Airtel takes it as transactionId, the externalId it returns
await airtelDisbursement.transfer('10000', '068511358', 'PAY-001', referenceId)

The id must be a UUID (any version): anything else throws before a request is sent. Without it, every method keeps generating a random UUID.

Handling callbacks

Callbacks are not signed. Neither MTN nor Airtel signs the request sent to your callback URL, and that URL is no secret: it travels in the X-Callback-Url header of every request. Anyone can call it with "status": "SUCCESSFUL". Never fulfil an order from the callback body. Treat the callback as a signal that something changed, then ask MTN or Airtel for the real status.

MTN MoMo

MTN sends the callback as a JSON body (PUT). Use it only to find the order, then re-query the payment with the reference id requestToPay() returned, which you stored with the order:

async function handleMomoCallback(body: { externalId?: string }) {
  // Look the order up in your own records; never trust the amount in the callback
  const order = await orders.findByExternalId(body.externalId ?? '')
  if (!order) return 404

  // Ask MTN, with the reference id stored when the payment was requested
  const transaction = await collection.getPaymentStatus(order.momoReferenceId)

  if (
    transaction.isSuccessful() &&
    transaction.getExternalId() === order.id &&
    Number(transaction.getAmount()) === Number(order.amount)
  ) {
    await order.markPaid()   // make this idempotent: MTN may call more than once
  } else if (transaction.isFailed()) {
    await order.markFailed(String(transaction.getReason()))
  }
  return 200
}

Airtel Money

Airtel's callback carries transaction.id, the externalId you passed to requestToPay(). Re-query it the same way:

async function handleAirtelCallback(body: { transaction?: { id?: string } }) {
  const order = await orders.findByExternalId(body.transaction?.id ?? '')
  if (!order) return 404

  // Ask Airtel; the callback's status_code is not proof of payment
  const transaction = await airtelCollection.getPaymentStatus(order.id)

  if (transaction.isSuccessful()) {
    await order.markPaid()
  } else if (transaction.isFailed()) {
    await order.markFailed(transaction.getMessage() ?? '')
  }
  return 200
}

Transaction.parse() turns an object into a Transaction; it does not verify anything. Use it on data you fetched yourself, not on a callback you intend to act on.

Error handling

All API errors are surfaced as typed exceptions that extend MomoException:

import {
  MomoException,
  BadRequestException,
  InvalidSubscriptionKeyException,
  ResourceNotFoundException,
  ConflictException,
  InternalServerErrorException,
} from '@lepresk/momo-api'

try {
  const referenceId = await collection.requestToPay(request)
} catch (err) {
  if (err instanceof InvalidSubscriptionKeyException) {
    console.error('Check your subscription key and API credentials')
  } else if (err instanceof BadRequestException) {
    console.error('Invalid request parameters')
  } else if (err instanceof MomoException) {
    console.error(`MoMo API error (${err.statusCode}):`, err.message)
  }
}

MomoException carries the parsed API payload: message is the API's own message, reason an ErrorReason with the helpers described below, code its machine-readable code (NOT_ENOUGH_FUNDS, ...) and body the raw response.

catch (err) {
  if (err instanceof MomoException && err.reason?.isNotEnoughFunds()) {
    console.error('Payer has insufficient funds')
  }
}

Why a payment failed

A failed transaction carries an ErrorReason:

import { ErrorReason } from '@lepresk/momo-api'

const transaction = await collection.getPaymentStatus(referenceId)

if (transaction.isFailed()) {
  const reason = transaction.getReason()
  // every MTN failure code has a predicate, or use reason.is(ErrorReason.X)

  // isPayerFundingFailure() also covers LOW_BALANCE_OR_PAYEE_LIMIT_REACHED_OR_NOT_ALLOWED,
  // which MTN Congo returns instead of NOT_ENOUGH_FUNDS
  if (reason?.isPayerFundingFailure()) {
    console.error('Payer has insufficient funds or reached a limit')
  } else if (reason?.isExpired()) {
    console.error('The payment request expired')
  } else {
    console.error(String(reason))   // "[COULD_NOT_PERFORM_TRANSACTION]"
  }
}

API reference

CollectionApi

| Method | Description | |---|---| | requestToPay(request, referenceId?) | Initiate a payment request; returns the reference ID | | getPaymentStatus(paymentId) | Get the status of a payment | | getBalance() | Get the collection account balance | | quickPay(amount, phone, reference, currency?, referenceId?) | Shorthand to initiate a payment | | getAccessToken() | Retrieve an OAuth access token |

DisbursementApi

| Method | Description | |---|---| | deposit(request, referenceId?) | Deposit funds to a customer; returns the reference ID | | getDepositStatus(depositId) | Get the status of a deposit | | transfer(request, referenceId?) | Transfer funds; returns the reference ID | | getTransferStatus(transferId) | Get the status of a transfer | | refund(request, referenceId?) | Refund a previous payment; returns the reference ID | | getRefundStatus(refundId) | Get the status of a refund | | getBalance() | Get the disbursement account balance | | getAccessToken() | Retrieve an OAuth access token |

AirtelCollectionApi

| Method | Description | |---|---| | requestToPay(amount, phone, reference, transactionId?) | Request payment from customer; returns the external ID | | getPaymentStatus(externalId) | Check payment status (returns AirtelTransaction) | | getBalance() | Get account balance | | getAccessToken() | Get OAuth token (cached automatically) |

AirtelDisbursementApi

| Method | Description | |---|---| | transfer(amount, phone, reference, transactionId?) | Transfer money (requires encryptedPin); returns the external ID | | getTransferStatus(externalId) | Check transfer status (returns AirtelTransaction) | | getBalance() | Get account balance | | getAccessToken() | Get OAuth token (cached automatically) |

SandboxApi

| Method | Description | |---|---| | createApiUser(apiUser, callbackHost) | Provision a sandbox API user | | getApiUser(apiUser) | Get sandbox API user details | | createApiKey(apiUser) | Generate an API key for a sandbox user |

Ecosystem

The same client is available for multiple languages:

| Language | Package | Install | |----------|---------|---------| | PHP | lepresk/momo-api | composer require lepresk/momo-api | | Node.js / TypeScript | @lepresk/momo-api | npm install @lepresk/momo-api | | Python | mtn-momo-client | pip install mtn-momo-client |

Contributing

Contributions are welcome! See CONTRIBUTING.md for guidelines.

Changelog

See CHANGELOG.md for the full history of changes.

License

MIT