PesaVoucher Mobile Money Integration Guide
Pan-African mobile money collections and payouts — one API, one callback contract, twenty countries.
This document explains how to initiate mobile money deposits and payouts through PesaVoucher, and what payload PesaVoucher sends to your callback_url once a transaction resolves. It follows the same conventions as PesaVoucher's other automated payment channels (STK Push, Paybill) — if you've integrated those, this will look familiar.
| Coverage | 20 countries, 16 currencies, every major mobile money wallet (MTN, Airtel, Orange, Vodacom, M-Pesa, Moov, Wave, Tigo, Halotel, TNM, Zamtel, and more) |
| Flows | Deposits (collect from a customer), payouts (disburse to a customer), refunds |
| Model | Asynchronous — every request returns an acceptance, the outcome arrives later by callback or status check |
| Auth | X-Api-Key + X-Api-Secret on every endpoint — the same credentials used for your other PesaVoucher integrations |
Unlike a bank card charge, a mobile money transaction is never resolved synchronously — it always requires the customer to authorise on their handset (or, for a small number of providers, via a redirect). Your integration should be built around that: initiate, then wait for the callback or poll status. Never assume a request that returned "accepted" has been paid.
Every endpoint below requires:
X-Api-Key: <your key>X-Api-Secret: <your secret>
There is no lower-friction alternative (no merchant-id-only path) — payouts move money outward, and a guessable identifier alone is not enough to authorise a disbursement.
POST /api/v1/momo/depositX-Api-Key: <key>X-Api-Secret: <secret>Content-Type: application/json{"amount": "150","currency": "ZMW","phone_number": "+260 763 456789","country": "ZMB","account_reference": "ORDER-123","customer_message": "PesaVoucher topup"}
{"success": true,"transaction_id": "afb57b93-7849-49aa-babb-4c3ccbfe3d79","status": "ACCEPTED","provider": "MTN_MOMO_ZMB","provider_name": "MTN","provider_predicted": true,"country": "ZMB","currency": "ZMW","amount": "150.00","message": "Deposit accepted. The customer must authorise it on their handset."}
ACCEPTED means the PIN prompt is on its way — not that you've been paid. The outcome arrives by callback (Section 7) or by polling GET /api/v1/momo/status (Section 6).
A national-format number (0763456789) is valid in almost every market this API covers, so it cannot be safely expanded without knowing the country — guessing would risk addressing the request to the wrong country's network. Always send phone_number in full international format (+260...) together with the ISO country code.
Omit provider and PesaVoucher will detect it automatically from the phone number. Supply it explicitly if your checkout UI already asked the customer which wallet they're paying from — this skips a lookup and is marginally faster.
GET /api/v1/momo/providers returns the live list of providers, per-provider transaction limits, and two flags described below, so your checkout UI can branch without hardcoding exceptions.
| Type | What changes |
|---|---|
requires_redirect: true | No PIN prompt. The response carries a redirect_url — you must send the customer there or the deposit expires unpaid. |
requires_preauth: true | The customer must complete a pre-authorisation step in their wallet app before you call deposit, and you pass the resulting preauth_code. Without it, the deposit is always rejected. |
Same request shape as a deposit, with phone_number naming the recipient.
POST /api/v1/momo/payoutX-Api-Key: <key>X-Api-Secret: <secret>Content-Type: application/json{"amount": "150","currency": "ZMW","phone_number": "+260 763 456789","country": "ZMB","account_reference": "PAYOUT-456"}
Two things are different about payouts:
They draw down a float balance. A payout is funded from a balance you've already topped up with PesaVoucher for that country/currency. If your float is insufficient, the request fails at that check — before anything is sent to a network.
ENQUEUED is a real, non-final outcome. Some networks can't process disbursements in the moment; rather than rejecting the payout, it's accepted and queued for later processing:
{"status": "ENQUEUED","cancellable": true,"message": "This network is not processing disbursements right now, the payout has been queued..."}
The funds are committed but have not moved yet. Treat this as pending, not success and not failure — crediting a recipient's ledger at this point risks paying them twice if the payout later fails elsewhere in your reconciliation, and marking it failed risks the reverse. You can cancel a still-ENQUEUED payout with POST /api/v1/momo/cancel_payout; once it starts processing, it can no longer be cancelled.
POST /api/v1/momo/refundX-Api-Key: <key>X-Api-Secret: <secret>Content-Type: application/json{ "transaction_id": "<the deposit's transaction_id>", "amount": "50" }
Omit amount for a full refund. A refund reverses a specific deposit and needs no recipient details, since the destination is the original payer and cannot be redirected by the caller. It is only accepted if the deposit belongs to you, has actually completed, and the amount doesn't exceed what's left to refund.
GET /api/v1/momo/status?transaction_id=afb57b93-7849-49aa-babb-4c3ccbfe3d79X-Api-Key: <key>X-Api-Secret: <secret>
{"success": true,"transaction_id": "afb57b93-7849-49aa-babb-4c3ccbfe3d79","type": "deposit","status": "COMPLETED","is_final": true,"payment_status": "Success","provider": "MTN_MOMO_ZMB","country": "ZMB","currency": "ZMW","requested_amount": "150.00","amount": "150.00","provider_transaction_id": "...","created_at": "2026-07-29T09:14:02Z","updated_at": "2026-07-29T09:14:31Z"}
Use this whenever a callback hasn't arrived within the timing window in Section 9, or as a periodic reconciliation check against your own records. It's safe to call at any time and does not re-trigger anything.
PesaVoucher sends a callback to your registered callback_url once a transaction reaches a final outcome — server-side, regardless of whether the customer is still on the checkout page.
HTTP POST, Content-Type: application/json, header X-Callback-Type: Mobile-Money:
{"transaction_id": "afb57b93-7849-49aa-babb-4c3ccbfe3d79","type": "deposit","status": "Success","provider": "MTN_MOMO_ZMB","country": "ZMB","currency": "ZMW","requested_amount": "150.00","amount": "150.00","phone_number": "+260763456789","provider_transaction_id": "8917...","account_reference": "ORDER-123","failure_code": null,"failure_message": null,"timestamp": "2026-07-29T09:14:31Z"}
| Field | Description |
|---|---|
transaction_id | PesaVoucher's transaction reference — use this for reconciliation/support queries. |
type | deposit, payout, or refund. |
status | Success, Failed, Cancelled, or Processing (see status table below). Only Success means funds have actually moved in your favour. |
provider | The network the transaction routed through, e.g. MTN_MOMO_ZMB. |
country / currency | Where and in what currency the transaction settled. |
requested_amount | What was requested at initiation. |
amount | What was actually confirmed — treat this as the source of truth, not requested_amount. |
phone_number | The customer's (deposit) or recipient's (payout) number. |
provider_transaction_id | The mobile network operator's own reference for the transaction. |
account_reference | The reference you supplied when initiating. |
failure_code / failure_message | Populated only on Failed. |
timestamp | When PesaVoucher sent this callback — not the transaction time. |
status | Meaning |
|---|---|
Success | Transaction completed — funds confirmed. Safe to credit/settle. |
Processing | Still in flight (includes queued payouts). Not final — do not treat as failure. |
Cancelled | Customer did not authorise, or a payout was cancelled before processing started. |
Failed | Transaction did not go through — do not credit. Check failure_code/failure_message. |
- Only act on
status: "Success". TreatProcessingas a no-op;Failed/Cancelledjust update your own record. - Respond
2xxpromptly. Non-2xx responses are retried; if delivery keeps failing, reconcile viatransaction_idwith PesaVoucher support. - Be idempotent. The same
transaction_idmay arrive more than once — don't double-credit. - Settle on
amount, notrequested_amount—amountis authoritative.
- Most deposits resolve within seconds to low minutes of the customer authorising.
- Redirect-based providers (no PIN prompt) resolve on the customer's own schedule after being sent to the redirect URL — these can take longer.
- If no callback has arrived within a few minutes, poll
GET /api/v1/momo/statusrather than assuming failure or re-submitting the request (re-submitting a deposit risks charging the customer twice).
PesaVoucher provides a sandbox environment with test phone numbers that deterministically produce each outcome (success, insufficient balance, wrong PIN, timeout, unregistered wallet). Contact PesaVoucher's integration team for sandbox credentials and the current test-number list before going live.
- Confirm your callback endpoint responds
2xxto a manual testPOSTbefore running any sandbox transaction. - Run a sandbox deposit and confirm the callback matches the shape in Section 7.
- Force a failure case (e.g. the "insufficient balance" test number) and confirm your handler treats
Failedcorrectly. - Re-send your own handler the same payload and confirm it doesn't double-credit.
- Repeat for a payout, including the
ENQUEUEDpath if your test account can trigger it.
For any transaction dispute or missing callback, provide PesaVoucher with the transaction_id (preferred) or account_reference — these map directly to the authoritative record on PesaVoucher's side.