Rwanda Mobile Money Gate
Accept Airtel Money and MTN MoMo pay-ins on a hosted page where the payer approves a push on their phone, and send mobile-money payouts in Rwandan Franc (RWF).
When to use it
The Rwanda Mobile Money Gate accepts mobile-money pay-ins (Airtel Money and MTN MoMo) on a MagicPayments-hosted page, and sends mobile-money payouts to a phone number. All amounts are in RWF (Rwandan Franc).
| Operation | Rail | How the integration looks |
|---|---|---|
| Pay-in | Mobile money (push to payer) | Create an invoice, redirect the payer to the hosted page. |
| Pay-out | Mobile money (phone) | Server-to-server call to the phone payout endpoint. |
The Rwandan Franc has 0 decimal places, so the minor unit equals the major one:
"amount": 5000 means 5,000 RWF — not 50.00. Sending 500000 for a 5,000 RWF
order overcharges the payer by 100×. This is the single most common mistake on this corridor.
You need your signing credentials from Getting started,
one pay-in gate_id per operator (Airtel Money and MTN MoMo are separate
gates), and one pay-out cascade_id per operator. Your
account manager provides all of them.
Pay-in — hosted mobile-money page
The payer never touches your servers with their payment details. You create an invoice; we host the page where they enter their own mobile-money number and approve the charge on their handset.
Each operator is its own gate, because the operator is fixed before the page opens — the payer is not
asked to pick one. Choose the gate_id by the payer's number:
| Operator | Number starts with | Gate |
|---|---|---|
| Airtel Money | 072, 073 | your Airtel gate_id |
| MTN MoMo | 078, 079 | your MTN gate_id |
-
Create the invoice
Call the gate endpoint with the operator's
gate_idand the amount in RWF. If you already know the payer's number, pass it incustomer.phone_number— the page pre-fills it. -
Redirect the payer
Send the customer's browser to the
invoice_urlwe return. The page is branded for that operator, accepts only that operator's numbers, and shows that operator's approval steps. The payer confirms the number and presses Pay. - The payer approves on their phone The operator pushes a confirmation request to the handset and the payer enters their mobile-money PIN. On some operators the payer is instead sent to the operator's own confirmation page — we handle that redirect on the hosted page, so nothing changes on your side.
-
Receive the result
We call your
callback_urlon every status change and you can poll invoice status as a fallback.
/api/invoice{
"gate_id": "rw-mtn-momo-gate",
"invoice": {
"invoice_id": "order-rw-2026-000123",
"currency": "RWF",
"amount": 5000,
"description": "Wallet top-up",
"ttl_minutes": 30
},
"customer": {
"id": "cust-77120",
"full_name": "Mugisha Jean",
"email": "[email protected]",
"phone_number": "+250788123456"
},
"workflow_hooks": {
"callback_url": "https://merchant.example.com/mp/callbacks",
"return_success_url": "https://merchant.example.com/orders/123/done",
"return_decline_url": "https://merchant.example.com/orders/123/retry"
}
}
Python
resp = signed_post("/api/invoice", {
"gate_id": "rw-mtn-momo-gate", # the MTN gate — +250 78… number below
"invoice": {
"invoice_id": "order-rw-2026-000123",
"currency": "RWF",
"amount": 5_000, # 5,000 RWF — RWF has 0 decimal places
"description": "Wallet top-up",
"ttl_minutes": 30,
},
"customer": {
"id": "cust-77120",
"full_name": "Mugisha Jean",
"email": "[email protected]",
"phone_number": "+250788123456",
},
"workflow_hooks": {
"callback_url": "https://merchant.example.com/mp/callbacks",
"return_success_url": "https://merchant.example.com/orders/123/done",
"return_decline_url": "https://merchant.example.com/orders/123/retry",
},
})
invoice = resp.json()
redirect_url = invoice["invoice_url"] # send the payer here
Response
{
"request_id": "0f0b1d2e-...",
"request_status": "success",
"invoice_id": "62051f64-9a79-4edb-8a9e-95c86c55ee4e",
"merchant_invoice_id": "order-rw-2026-000123",
"invoice_status": "unpaid",
"invoice_url": "https://stage.example-mp.com/public/invoice/62051f64-.../gate",
"message": "Invoice (Gate PayIn) with internal uid=62051f64-... has been created."
}
Rwandan mobile-money operators require the payer's full name and email on the charge request. Pass
customer.full_name and customer.email when you create the invoice — if you leave
them out, the operator may reject the charge before the payer ever sees the push.
Tracking the pay-in
Look up the invoice by your own id (or our invoice_uid). It reaches paid on success.
/api/invoice/statusresp = signed_post("/api/invoice/status", {
"merchant_invoice_id": "order-rw-2026-000123"
})
invoice = resp.json()
# invoice["status"] -> "paid" | "payment_failed" | "expired" | "canceled" | ...
# invoice["actual_payment"]["processing_info"]["amount_acquired"] -> settled RWF
The charge sits in processing until the payer approves the push on their handset, which
can take a couple of minutes. Set a ttl_minutes that gives them room, and treat
processing as "not finished yet" rather than as a failure.
Pay-out — mobile money to a phone
Payouts go server-to-server. The destination is a phone number plus the operator that
serves it. Like pay-ins, payouts are split per operator: use the cascade_id of the
recipient's operator, and set phone.provider to the same operator — a mismatch is rejected
before anything reaches the operator.
| Operator | phone.provider | Cascade |
|---|---|---|
| Airtel Money | airtel | your Airtel payout cascade_id |
| MTN MoMo | mtn | your MTN payout cascade_id |
/api/payment/payout/phone{
"cascade_id": "rw-mtn-payout",
"payment": {
"payment_id": "payout-rw-2026-000045",
"currency": "RWF",
"amount": 30000,
"description": "Affiliate payout"
},
"phone": {
"provider": "mtn",
"phone_number": "+250788123456"
},
"customer": {
"id": "partner-2391",
"full_name": "Uwase Claire"
},
"workflow_hooks": {
"callback_url": "https://merchant.example.com/mp/callbacks"
}
}
Python
resp = signed_post("/api/payment/payout/phone", {
"cascade_id": "rw-mtn-payout",
"payment": {
"payment_id": "payout-rw-2026-000045",
"currency": "RWF",
"amount": 30_000, # 30,000 RWF
"description": "Affiliate payout",
},
"phone": {"provider": "mtn", "phone_number": "+250788123456"},
"customer": {"id": "partner-2391", "full_name": "Uwase Claire"},
"workflow_hooks": {"callback_url": "https://merchant.example.com/mp/callbacks"},
})
payout = resp.json()
payout_uid = payout["payment_id"] # our uid; status starts at "initiated"
Rwandan mobile-money payouts carry the recipient's name to the operator, so
customer.full_name is required. Omit it and the payout is rejected outright — it never
reaches the operator.
Tracking the pay-out
Payouts use the payment status endpoint (not the invoice one).
/api/payment/statusresp = signed_post("/api/payment/status", {
"merchant_payment_id": "payout-rw-2026-000045"
})
# resp.json()["status"] -> "success" | "processing" | "decline" | "error"
Phone number format
Send the destination as the local 07XXXXXXXX form, the full 2507XXXXXXXX form, or
in international format (+250 788 123 456) — spaces, dashes and parentheses are accepted and
stripped. What matters is that phone.provider matches the operator that actually serves the
number: a mismatch is the most common cause of a decline on payout.
Testing & go-live
- On stage, use the test
gate_id/cascade_idfrom your account manager; no real funds move. - Verify you can drive a pay-in to
paidand a payout tosuccess, and that your callback handler verifies the signature and is idempotent. - Confirm your RWF amount conversion once more before go-live:
amountis a whole number of francs, with no ×100 anywhere in your code. - Check both operators end to end with both gates — an integration that works on one is not proof the other is routed. Sending an MTN number to the Airtel gate is rejected on the page, not by the operator.
- Per-transaction limits apply on this corridor (roughly 100 RWF to 10,000,000 RWF); your account manager confirms the exact range for your terminal.
- Swap base URL and credentials to production; the request shapes are identical.