MagicPayments Integration Guides

Rwanda Mobile Money Gate

Pay-inPay-outRWFAirtel / MTN

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).

OperationRailHow the integration looks
Pay-inMobile money (push to payer)Create an invoice, redirect the payer to the hosted page.
Pay-outMobile money (phone)Server-to-server call to the phone payout endpoint.
RWF has no minor unit

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.

Prerequisites

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:

OperatorNumber starts withGate
Airtel Money072, 073your Airtel gate_id
MTN MoMo078, 079your MTN gate_id
  1. Create the invoice Call the gate endpoint with the operator's gate_id and the amount in RWF. If you already know the payer's number, pass it in customer.phone_number — the page pre-fills it.
  2. Redirect the payer Send the customer's browser to the invoice_url we 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.
  3. 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.
  4. Receive the result We call your callback_url on every status change and you can poll invoice status as a fallback.
POST/api/invoice
Request body
{
  "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."
}
Send the customer name and email

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.

POST/api/invoice/status
resp = 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 payer decides how fast this moves

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.

Operatorphone.providerCascade
Airtel Moneyairtelyour Airtel payout cascade_id
MTN MoMomtnyour MTN payout cascade_id
POST/api/payment/payout/phone
Request body
{
  "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"
The recipient name is mandatory

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).

POST/api/payment/status
resp = 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