Cash in
Charging a customer's MTN MoMo or Airtel Money wallet. They get a PIN prompt; you get a webhook.
The endpoint
/v1/paymentsApp keycurl 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": "0788924941",
"description": "Order #1043",
"reference": "1043",
"metadata": { "order_id": "1043" }
}'The 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 | 0788924941, 250788924941 or +250 788 924 941. We normalise all of them. |
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. |
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": 620,
"currency": "RWF",
"msisdn": "250788924941",
"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.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
3.1% of the amount for your first 3 months and 2.3% after that, 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 | 620 RWF | 19,380 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 key 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. |
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 | 24 hours after the payment succeeds |