Cash in
Charging a customer's MTN MoMo or Airtel Money wallet, one of two ways. You get a webhook either way.
Two ways to pay
| method | What the customer does | Works with |
|---|---|---|
"prompt" | Approves a PIN prompt that appears on their phone. The default. | MTN and Airtel |
"dial" | Dials a short code we give you, then enters their PIN. Pay by dialing | MTN |
Same endpoint, same price (2.5%), same webhooks. Send method to choose; leave it out for the prompt.
The endpoint
/v1/paymentscurl https://api.xendlypay.com/v1/payments \
-H "Authorization: Bearer $XENDLY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1043" \
-d '{
"amount": 5000,
"currency": "RWF",
"msisdn": "250781234567",
"description": "Order #1043",
"reference": "1043",
"metadata": { "order_id": "1043" }
}'Or without a key at all
If you are signed in, this sends the same payment through your dashboard session instead: pick one of your apps, type a registered test number, and the prompt arrives in a few seconds. Nothing to paste, and useful when you have not created a key yet.
Try it, for real
Sign in and create an app, and this box becomes a real payment you can send from this page.
Go to your dashboardThe body
| Field | Type | Required | Notes |
|---|---|---|---|
amount | integer | yes | Minor units. RWF has no subdivision, so 20000 is twenty thousand francs. A float here is a rounding argument with a customer later. |
msisdn | string | yes | The customer’s MTN or Airtel Rwanda number, in a field named exactly msisdn. 0781234567, 250781234567 and +250 781 234 567 all work: we normalise them, and every response and webhook gives it back as 250781234567. Missing or malformed, you get a 400: a valid mobile money number is required. With a test key, the number must also be on your test list. |
currency | string | no | RWF. It is the only value, and it is the default. |
description | string | no | What the payment is for. Shown in your dashboard and on exports; not shown to the customer. |
reference | string | no | Your own id for this. Echoed on every webhook, and the cheapest way to join our record to your order table. |
metadata | object | no | Arbitrary JSON, stored and returned untouched. We never read it. |
method | string | no | How the customer pays. "prompt" (the default) sends a PIN prompt to their phone. "dial" has them dial a short code instead; see Pay by dialing. |
Send an Idempotency-Key
What comes back
201 Created
{
"success": true,
"payload": {
"id": "0f8c2b7e-1a45-4c31-9d02-6e8f1b2c3d44",
"status": "pending",
"direction": "collect",
"amount": 20000,
"fee": 500,
"currency": "RWF",
"msisdn": "250781234567",
"network": "mtn",
"reference": "1043",
"created_at": "2026-09-12T09:14:02Z",
"completed_at": null
},
"timestamp": "2026-09-12T09:14:02.000Z"
}pending is not paid
payment.succeeded webhook and nothing else. The whole argument is here.Pay by dialing
Some customers would rather pay the way they already pay in shops: dial a short code and type their PIN. Send "method": "dial" and no prompt is sent. The payment comes back pending with a pay_with object saying exactly what to dial, and it succeeds the moment their money reaches our line, usually within seconds of them confirming.
POST /v1/payments
{
"amount": 5000,
"msisdn": "250781234567",
"method": "dial",
"reference": "1043"
}
201 Created
{
"success": true,
"payload": {
"id": "0f8c2b7e-1a45-4c31-9d02-6e8f1b2c3d44",
"status": "pending",
"method": "dial",
"amount": 5000,
"msisdn": "250781234567",
"network": "mtn",
"reference": "1043",
"pay_with": {
"method": "dial",
"code": "123456",
"name": "XENDLY PAY",
"amount": 5000,
"ussd": "*182*8*1*123456*5000#",
"qr": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i…",
"expires_at": "2026-10-25T16:14:02Z"
}
}
}| Field | Show the customer |
|---|---|
pay_with.ussd | What to dial, amount included. On a phone, make it a link: <a href="tel:*182*8*1*123456*5000%23"> (the # written as %23) opens the dialler with it filled in. |
pay_with.qr | A QR code of that same dial, as an image you can put straight in an <img src="…">. The customer scans it with the paying phone's camera and the dialler opens with the code and amount filled in; they only enter their PIN. Show it on a computer or a till screen; on the paying phone itself, the tel: link is easier. |
pay_with.name | The name MoMo shows them before they confirm. Tell them to expect it, so they do not stop at a name they do not recognise. |
pay_with.expires_at | When we stop waiting. Unpaid by then, the payment fails with failure_code NOT_PAID and you get payment.failed. |
Three rules make it work:
- They must pay from the number you sent, and the exact amount. That pair is how their payment is recognised; from another phone, or a different amount, it is not matched to this payment.
- MTN only, for now. An Airtel number is refused with a 400; use the prompt for Airtel customers.
- Fulfil on the webhook, as with a prompt:
payment.succeededwhen the money arrives,payment.failedif it never does.GET /v1/payments/:idalso returnspay_withwhile the payment is pending, so you can show the instructions again.
It costs the same as a prompt: 2.5% of the amount, and nothing if it is never paid.
With the checkout widget
"method": "dial" when you create the checkout to open on it. Nothing to build.Which network it goes over
You do not choose, and there is no provider field to get wrong. We read the prefix and route accordingly:
| Prefix | Network | Field value |
|---|---|---|
| 078, 079 | MTN MoMo | "mtn" |
| 072, 073 | Airtel Money | "airtel" |
| anything else | Not a mobile money number we can reach | "unknown" |
A number we cannot route is refused at the door with a 400, before a payment row exists, so a typo costs you a rejected request, not a transaction to explain.
What it costs
2.5% of the amount, taken from what we owe you. The customer pays the amount you asked for and not a franc more.
| Customer pays | Our fee | You keep |
|---|---|---|
| 20,000 RWF | 500 RWF | 19,500 RWF |
A failed or cancelled payment costs nothing. The fee is recorded on the transaction at the rate that applied when it happened, so a price change never restates yesterday. See fees and settlement.
When it does not work
| Status | Means | Do |
|---|---|---|
400 | The amount is not a positive integer, or the number is not MTN/Airtel. | Fix the request. Retrying will not help. |
401 | The key is wrong, revoked, or edited. | Check which key the environment is holding. |
403 | A live app on an unverified business, or a disabled app. | Finish verification, or re-enable the app. |
402 / 409 | The network refused: declined, no wallet, or a limit exceeded. | Show the customer the message and offer to retry. |
429 | Over your per-minute budget. | Wait for Retry-After and retry. Nothing was recorded. |
400 INVALID_METHOD | An unknown method, or dial with an Airtel number or a currency other than RWF. | Send a prompt instead, or fix the request. |
503 METHOD_UNAVAILABLE | Paying by dialing is switched off right now. | Fall back to the prompt. |
Every failure body carries a human-readable message. Errors and limits has the shape and the full list.
Limits
| Minimum | 100 RWF |
| Maximum | Whatever the customer's wallet and the network allow. MTN and Airtel each cap per transaction and per day, and those caps are theirs, not ours. |
| Settlement | Immediate. Withdrawable as soon as the payment succeeds. |