@halifage/mobile-money
v0.4.2
Published
Mobile money collections and payouts for Cameroon: Orange and MTN direct, pawaPay
Readme
@halifage/mobile-money
Mobile money collections and payouts for Cameroon. Talks to Orange Money and MTN MoMo directly, or through pawaPay,
and returns normalised outcomes. It stores nothing and settles nothing: your app persists the ref, confirms every payment with
status(), and settles.
Node 22+, ESM, no runtime dependencies.
npm install @halifage/mobile-moneyUsage
import { MobileMoneyError, orangeDirect, signCallbackUrl, verifyCallback } from '@halifage/mobile-money'
// one instance per process: it caches the provider token
const orange = orangeDirect({
username: process.env.ORANGE_USERNAME!,
password: process.env.ORANGE_PASSWORD!,
authToken: process.env.ORANGE_AUTH_TOKEN!,
merchantMsisdn: process.env.ORANGE_MERCHANT_MSISDN!,
pin: process.env.ORANGE_PIN!,
})
// start a payment
try {
const { ref } = await orange.collect({
id: attempt.id, // your UUID, the idempotency key
operator: 'orange',
amount: 500,
phone: '+237690000000',
description: 'Order 42',
callbackUrl: signCallbackUrl('https://app.example/api/momo/callback', attempt.id, process.env.CALLBACK_SECRET!),
})
// save ref on the attempt
} catch (error) {
if (error instanceof MobileMoneyError && error.kind === 'uncertain') {
// may have reached the provider: save error.ref when set and keep polling status()
}
// rejected: nothing moved, offer a retry with a new id
}
// callback route: the body is never trusted
export async function POST(request: Request) {
const id = verifyCallback(request, process.env.CALLBACK_SECRET!)
if (!id) return new Response(null, { status: 401 })
const outcome = await orange.status(refForAttempt(id))
// settle when outcome.status === 'completed'
return new Response(null, { status: 204 })
}Outcomes
| status() result | Meaning |
|---|---|
| { status: 'completed', amount, currency } | Paid. Check amount against what you asked for. |
| { status: 'failed', reason, code? } | Final. reason: insufficient_funds, declined, expired, wrong_number, unavailable, unknown. |
| { status: 'pending', code? } | Not final yet, including any status string the SDK does not know. Poll again. pawaPay sets code: 'NOT_FOUND' while the payer has not pressed Pay. |
Errors
MobileMoneyError has kind, code and, when known, ref.
| kind | When | What to do |
|---|---|---|
| rejected | collect refused or nothing was sent (invalid_input, TOKEN_FAILED, provider 4xx) | No money moved. Retry with a new id. |
| uncertain | collect may have reached the provider (timeout, network error, 5xx) | Save ref if set and poll status(). Without ref, reconcile by hand. |
| unavailable | status() lookup failed | Retry later. |
Providers
| Factory | Operators | Config |
|---|---|---|
| orangeDirect | orange | username, password, authToken (X-AUTH-TOKEN), merchantMsisdn, pin, baseUrl? (default https://api-s1.orange.cm) |
| orangeWebPay | orange | clientId, clientSecret, merchantKey, environment (dev sandbox in test currency OUV, or cm), baseUrl? (default https://api.orange.com) |
| mtnDirect | mtn | subscriptionKey, apiUser, apiKey, environment (sandbox or mtncameroon), baseUrl? |
| pawaPay | mtn, orange | token (API token from the pawaPay dashboard), environment (sandbox or production), baseUrl? |
Orange wants a 9-digit local payer number; the SDK strips
+237.orangeWebPayis redirect-based: passreturnUrl, then send the payer to theredirectUrlthatcollectreturns, where they confirm with an Orange Money OTP. Itsrefis opaque base64url (it carries the pay token, order id and amount the status call needs). No merchant PIN is involved. A lostcollectreply isrejected: without the payment page the payer cannot pay.MTN requires
idto be a UUID and uses it as theref. Sandbox works in EUR and accepts MTN's test numbers.MTN
status()returnsfailedwith codeNOT_FOUNDfor a payment MTN never received. After anuncertaincollect, wait a few minutes before treatingNOT_FOUNDas final.pawaPaycollects through its hosted Payment Page: passreturnUrl, send the payer toredirectUrl.idmust be a UUID and is theref; the payer's number is fixed on the page. A deposit stayspendingwith codeNOT_FOUNDuntil the payer presses Pay; the page expires after 15 minutes without a callback, so the caller decides when a longNOT_FOUNDis dead.callbackUrlis ignored: pawaPay posts callbacks to the URLs set in its dashboard.
Payouts
pawaPay() also implements MobileMoneyPayouts:
const { ref } = await provider.payout({ id: uuid, operator: 'orange', amount: 20000, phone: '+237693456789', message: 'Ecolage versement' })
await provider.payoutStatus(ref) // completed | failed (reason 'unknown', code = pawaPay failureCode) | pendingThe recipient gets exactly amount; pawaPay takes its fee from your wallet. message (4–22 chars) is shown to the
recipient, else omitted. Re-sending the same id is safe (pawaPay answers DUPLICATE_IGNORED). rejected means
nothing was sent; uncertain (network error, 5xx, UNKNOWN_ERROR) carries ref: poll payoutStatus. A payout
pawaPay never received is failed with code NOT_FOUND.
Callbacks
parsePawaPayCallback(rawBody) returns { kind: 'deposit' | 'payout' | 'refund', ref } or null. It never returns a
status: call status(ref) or payoutStatus(ref). Without callbacks configured, polling alone works.
Your app picks the provider per operator. To add another aggregator, implement
MobileMoneyProvider and run describeProviderContract from test/contract.ts against it.
Checkout UI
A React checkout for the payer, at @halifage/mobile-money/checkout. It renders from a provider-neutral status your
app owns; it never calls a provider, polls or stores anything. French by default, English included, no Tailwind needed.
npm install @halifage/mobile-money react react-dom # react >= 19, optional peer: only the checkout needs itimport { Checkout, type CheckoutStatus } from '@halifage/mobile-money/checkout'
import '@halifage/mobile-money/checkout.css'
<Checkout
amount={20100} // XAF, whole units
payee="Collège La Réussite"
status={status} // CheckoutStatus, from your app
onPay={({ operator, phone }) => start(operator, phone)} // phone: '237' + 9 digits
/>The root import @halifage/mobile-money stays React-free.
| Prop | |
|---|---|
| amount, payee | Shown on every step. |
| status | CheckoutStatus below. |
| onPay({ operator, phone }) | Called by Payer and by Réessayer, once per tap until status changes. |
| defaultOperator, defaultPhone | Prefill, e.g. after a reload in a failed state. Any common phone format. |
| summary | Extra lines under the amount. |
| actions | Shown on success, e.g. a receipt link. |
| locale | 'fr' (default) or 'en'. |
| messages | Per-string overrides, e.g. { payButton: 'Régler {amount}' }. |
| status | Shows |
|---|---|
| { status: 'idle' } | Amount, operator tiles (follow the number's prefix), number field, Payer |
| { status: 'initiated' } | Same form, disabled, spinner |
| { status: 'awaiting_customer', nextAction: { kind: 'approve_on_phone', operator } } | 3 steps to approve on the phone, folded "Pas reçu la demande ?" help (MTN: dial *126#) |
| { status: 'awaiting_customer', nextAction: { kind: 'redirect', url } } | "Redirection…", then window.location.assign(url) once |
| { status: 'pending_confirmation' } | "Nous vérifions votre paiement" |
| { status: 'succeeded', reference, paidAt } | Receipt rows, then actions |
| { status: 'failed', reason } / { status: 'expired' } | Reason message, one-tap retry to the same number, "Changer de numéro ou d'opérateur" |
The component keeps the operator and number itself; "Changer…" shows the form again until the next onPay. An error
before anything reached the operator is failed with reason unknown. Mapping a collect() result to NextAction:
const { ref, redirectUrl } = await provider.collect(request)
const nextAction: NextAction = redirectUrl
? { kind: 'redirect', url: redirectUrl }
: { kind: 'approve_on_phone', operator: request.operator }Theming: override these on .mm-checkout (classes are prefixed mm-). From 600 px of its own width the component
splits into two columns, summary left.
| Variable | Default |
|---|---|
| --mm-accent | #0a6b3b (active step, selected tile, main button) |
| --mm-danger | #b3261e |
| --mm-fg / --mm-muted | #222 / #6b6b6b |
| --mm-bg / --mm-border | #fff / #ddd |
| --mm-radius | 12px |
| --mm-font | inherit |

Logos: MTN 2022 logo and the Orange Money arrow mark, both from Wikimedia Commons. MTN, MoMo, Orange and Orange Money are trademarks of their owners. Swap in your merchant-kit files if you have them.
Observed provider behaviour
Orange (live, 2026-10-14): token,
mp/initand TLS verification work as documented.mp/payrejects anorderIdover 20 characters with HTTP 422, so the SDK sendsidwithout dashes, cut to 20. Status spellings: documentedPENDINGandSUCCESSFULL, plusFAILEDseen indata.status. A wrong merchant PIN returns HTTP 417 withinittxnstatus00036and blocks the merchant account (unblock with#150*1#from the merchant SIM), so check the PIN before going live. A completed (approved) payment has not been observed yet.Orange Web Payment: sandbox path
dev/v1with currencyOUV, productioncm/v1withXAF(per open-source integrations; official reference is gated). Token path (/oauth/v3/token),order_idlimits and status spellings are unconfirmed until a sandbox run.pawaPay sandbox (2026-10-18), all as mocked in the unit tests:
POST /v2/paymentpageanswers 201 withredirectUrland acceptsphoneNumber+amountDetailsfor CMR. The page shows the fixed number but still asks the payer to pick MTN or Orange. Deposit status isNOT_FOUNDuntil Pay is pressed, thenFOUNDwithCOMPLETEDorFAILED(PAYMENT_NOT_APPROVED,INSUFFICIENT_BALANCE) within seconds; amounts come back as"100.00". Payouts answerACCEPTEDand settle within 5 s toCOMPLETEDorFAILED(RECIPIENT_NOT_FOUND,WALLET_LIMIT_REACHED). Callbacks were not exercised (none configured): polling only. A non-httpsreturnUrl(http://localhost) is rejected with HTTP 400INVALID_PARAMETER; 4xx errors carryfailureReason.failureCode.MTN MoMo sandbox (2026-11-03): token,
requesttopay(202, empty body) and status polling work as documented, currencyEUR. Status spellings:CREATED(pending),SUCCESSFUL(withfinancialTransactionId) andFAILEDwith areason:APPROVAL_REJECTED,INTERNAL_PROCESSING_ERROR,PAYER_NOT_FOUND,INVALID_CURRENCY. The documented test numbers are unreliable:46733123451rejects and…454stays pending, but…450/…461/…455/…460fail with the reasons above;237670000000succeeds.POST /v1_0/apiuserreturned 500 for every request on 2026-10-14.
Development
npm test # vitest
npm run type-check
npm run lint # biome
npm run build # dist/
npm run smoke # pack, install, import
npm run demo # demo/index.html: every checkout state, staticRelease: GitHub → Actions → release → Run workflow, pick the bump (patch/minor/major) and the branch. It
runs the checks, commits chore: release X.Y.Z, tags vX.Y.Z, pushes, publishes to npm with provenance and creates a
GitHub Release with generated notes. Picking main first fast-forwards main to dev; both end on the release
commit. If dev or main moves during the run, the push is rejected and nothing is published. npm auth is trusted
publishing, no token: once, on npmjs.com → package → Settings → Trusted publisher, add GitHub Actions with owner
halifage, repository mobile-money, workflow release.yml.
Live Orange test (moves real money): copy .env.example to .env.live (all .env* files except the example are
gitignored), fill in the Orange credentials, then:
cp .env.example .env.live
npm run build && node --env-file=.env.live scripts/live-orange.mjs 6XXXXXXXX 10It prints every raw provider response with secrets redacted and polls status() for up to 3 minutes.
Orange Web Payment sandbox (no real money with ORANGE_WEBPAY_ENVIRONMENT=dev): fill the ORANGE_WEBPAY_* values in
.env.live, then open the printed payment URL and pay:
npm run build && node --env-file=.env.live scripts/live-orange-webpay.mjs 10pawaPay sandbox (no real money): put PAWAPAY_TOKEN in .env.live (sandbox test numbers: docs.pawapay.io), then:
npm run build && node --env-file=.env.live scripts/live-pawapay.mjs collect 237653456789 100 # open the URL, pay
node --env-file=.env.live scripts/live-pawapay.mjs payout 237693456789 100
node --env-file=.env.live scripts/live-pawapay.mjs listen 8787 # callback bodiesMTN MoMo sandbox (EUR, no real money): put the Collections primary key in .env.live as MTN_SUBSCRIPTION_KEY,
create a sandbox API user and key once (appended to .env.live), then run against a sandbox test number
(237670000000 successful, 46733123451 rejected, …454 pending; other 4673312345x numbers mostly fail):
node --env-file=.env.live scripts/mtn-sandbox-user.mjs >> .env.livenpm run build && node --env-file=.env.live scripts/live-mtn.mjs 237670000000 10