API reference
Every endpoint, every parameter. Short by design, because the why is on the pages either side of this one.
Conventions
| Base URL | https://api.xendlypay.com |
| Auth | Authorization: Bearer <key> or X-Api-Key |
| Content type | application/json, both directions |
| Amounts | Integers in minor units. RWF has no cents. |
| Timestamps | RFC 3339, UTC |
| Ids | UUIDs |
Successes are wrapped:
{
"success": true,
"payload": { "id": "0f8c2b7e…", "status": "pending", "amount": 5000 },
"timestamp": "2026-09-12T09:14:02.000Z"
}Errors are not. They key on status instead. See errors and limits.
Create a payment (cash in)
/v1/paymentsApp key| Field | Type | Required |
|---|---|---|
amount | integer, minor units, ≥ 100 | yes |
msisdn | string, MTN or Airtel RW | yes |
currency | string, RWF | no |
description | string | no |
reference | string | no |
metadata | object | no |
Header: Idempotency-Key, optional but strongly advised. Returns 201 with a pending transaction. Full page.
Create a payout (cash out)
/v1/payoutsApp keySame body as a payment. Requires an approved merchant, a live app, and an available balance covering amount plus fee. Returns 201; 409 when the balance will not cover it. Full page.
Refund a payment
/v1/payments/:id/refundApp key| Field | Type | Required |
|---|---|---|
amount | integer, omit to refund in full | no |
reason | string, up to 64 characters | no |
Only a succeeded cash in can be refunded, and only once. Returns 201 with a new payout carrying reverses_payment_id. Full page.
Retrieve a payment
/v1/payments/:idApp keyScoped to the key’s app: a payment belonging to another app answers 404, not 403. Authoritative and permanent, so it is safe to use for reconciliation.
List payments
/v1/paymentsApp key| Query | Values | Notes |
|---|---|---|
status | pending, succeeded, failed, cancelled, all | Omit or all for no filter |
kind | CASHIN, CASHOUT | Or direction=collect|payout |
network | mtn, airtel | Routed from the number, not chosen |
msisdn | any format | Normalised before matching, so 078… finds 250 78… |
from | YYYY-MM-DD | Inclusive |
to | YYYY-MM-DD | Inclusive of the whole day |
limit | 1–100, default 50. Anything outside that becomes 50. | |
offset | integer |
{
"success": true,
"payload": {
"payments": [ … ],
"total": 214
},
"timestamp": "2026-09-12T09:14:02.000Z"
}app_id and mode are not yours to set here
?app_id= would be an access-control decision made by the caller.Balance
/v1/balanceApp keyOptional ?currency=RWF. Returns pending and available for the key’s mode. Full page.
Apps
/v1/appsAccount key/v1/appsAccount keyAccount-scoped: these are the only endpoints an account key can reach, and it cannot move money on any of them. Creating an app takes a name; the new app is created in the key’s own mode. A test key that could mint a live app would be a privilege escalation dressed as a convenience.
The payment object
| Field | Type | Notes |
|---|---|---|
id | uuid | Ours. Store it against your order. |
kind | string | Alias for direction: CASHIN or CASHOUT |
direction | string | collect | payout |
status | string | pending | succeeded | failed | cancelled |
amount | integer | Minor units, what the customer paid or received |
fee | integer | Our cut, at the rate that applied when it happened |
currency | string | RWF |
msisdn | string | Country-coded: 250788924941 |
network | string | mtn | airtel | unknown |
mode | string | test | live |
reference | string | null | Yours |
metadata | object | null | Yours, untouched |
failure_code | string | null | The gateway's code, when it gave one |
failure_message | string | null | Readable reason |
initiated_by | string | api | dashboard: who asked |
reverses_payment_id | uuid | null | Set on a refund |
created_at | timestamp | |
completed_at | timestamp | null | When it reached a final status |
settled_at | timestamp | null | When it became withdrawable |