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

methodWhat the customer doesWorks 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 dialingMTN

Same endpoint, same price (2.5%), same webhooks. Send method to choose; leave it out for the prompt.

The endpoint

POST/v1/payments
curl 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" }
  }'
Paste a key and send this exact request from your browser.

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 dashboard

The body

FieldTypeRequiredNotes
amountintegeryesMinor units. RWF has no subdivision, so 20000 is twenty thousand francs. A float here is a rounding argument with a customer later.
msisdnstringyesThe 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.
currencystringnoRWF. It is the only value, and it is the default.
descriptionstringnoWhat the payment is for. Shown in your dashboard and on exports; not shown to the customer.
referencestringnoYour own id for this. Echoed on every webhook, and the cheapest way to join our record to your order table.
metadataobjectnoArbitrary JSON, stored and returned untouched. We never read it.
methodstringnoHow 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

Not required, and you should send one anyway. It is the difference between a timeout you can safely retry and a customer charged twice. How it works.

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

A 201 means the network was asked, not that anybody paid. Fulfil on the 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"
    }
  }
}
FieldShow the customer
pay_with.ussdWhat 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.qrA 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.nameThe 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_atWhen 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.succeeded when the money arrives, payment.failed if it never does. GET /v1/payments/:id also returns pay_with while 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.

Paste a key and send this exact request from your browser.

With the checkout widget

The hosted checkout offers both: the customer picks “Dial to pay” or “Get a prompt”, and dialing shows the code, the name and a tap-to-dial button. Pass "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:

PrefixNetworkField value
078, 079MTN MoMo"mtn"
072, 073Airtel Money"airtel"
anything elseNot 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 paysOur feeYou keep
20,000 RWF500 RWF19,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

StatusMeansDo
400The amount is not a positive integer, or the number is not MTN/Airtel.Fix the request. Retrying will not help.
401The key is wrong, revoked, or edited.Check which key the environment is holding.
403A live app on an unverified business, or a disabled app.Finish verification, or re-enable the app.
402 / 409The network refused: declined, no wallet, or a limit exceeded.Show the customer the message and offer to retry.
429Over your per-minute budget.Wait for Retry-After and retry. Nothing was recorded.
400 INVALID_METHODAn unknown method, or dial with an Airtel number or a currency other than RWF.Send a prompt instead, or fix the request.
503 METHOD_UNAVAILABLEPaying 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

Minimum100 RWF
MaximumWhatever 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.
SettlementImmediate. Withdrawable as soon as the payment succeeds.