Errors and limits
One envelope, honest status codes, and a clear line between “fix your request” and “try again in a moment”.
The shape
Errors key on status, where successes key on success. That asymmetry is not elegant and it is load-bearing: every Xendly service answers this way and clients switch on it.
{
"status": "error",
"message": "amount must be a positive whole number of minor units",
"timestamp": "2026-09-12T09:14:02.000Z"
}message is written for a human reading a log, and it says what was actually wrong rather than “invalid request”. Some errors add a details object; none of them leak anything internal.
The statuses
| Status | Means | Retry? |
|---|---|---|
400 | Your request is wrong: amount below 100 RWF, a number that is not MTN or Airtel, a body that will not parse, a refund larger than its payment. | No. It will be wrong next time too. |
401 | The key is missing, malformed, unknown, revoked, or its mode does not match its record. All five say the same thing on purpose. | No. Fix the key. |
403 | The key was fine, the action was not: an account key on a money endpoint, a live key on an unverified business, a disabled app, a suspended account. | No, until the underlying condition changes. |
404 | No such transaction, or one belonging to a different app. We do not distinguish the two. | No. |
409 | A real conflict: insufficient balance, or a payment already refunded. | Only after something changes: a top-up, a settlement. |
429 | Over your per-minute budget, or the gateway's. | Yes, after Retry-After. |
502 | The gateway failed in a way we could not interpret. | Yes, carefully, with the same idempotency key. |
503 | A dependency is briefly unavailable. Carries Retry-After. | Yes, after the header says. |
A 4xx from the network is still a 4xx
Retrying safely
For anything that moves money, retry only with the same Idempotency-Key. That turns a retry from a risk into a question, and is the single most important line of code in a payments integration.
- Back off exponentially. One second, two, four. A tight retry loop against a struggling service is how a short outage becomes a long one.
- Never retry a 400 or a 403. Nothing about them will have changed.
- Treat a timeout as unknown, not as failed. The payment may well exist. Retry with the key, or ask
GET /v1/payments/:id.
Rate limits
Budgets are per API key, per minute, and reads and writes are counted separately. Polling payment status cannot exhaust what creating a payment draws on.
| Budget | Per minute | Endpoints |
|---|---|---|
| Writes | 120 | POST /v1/payments, /v1/payouts, /refund, POST /v1/apps |
| Reads | 600 | GET /v1/payments, /v1/payments/:id, /v1/balance, /v1/apps |
Keyed on the API key, not your IP address. So scaling your servers out does not multiply your budget, and two companies behind one host do not share one.
Every response tells you where you stand:
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 41Over budget answers 429 with a Retry-After in seconds and "code": "RATE_LIMITED" in the body. Wait that long and try again; nothing about the request was recorded.
If you need more
The rails underneath have their own limits, and a 429 from them is passed straight through with the same shape. So one retry-on-429 path handles both.
Be reasonable with polling
GET /v1/payments/:id every few seconds while a customer watches a spinner is fine, and well inside the read budget. Polling every 200ms for an hour is neither, and is unnecessary — the webhook tells you the moment it happens.Transaction limits
| Minimum | 100 RWF, on cash in and cash out alike |
| Maximum | Set by MTN and Airtel, per transaction and per day, per customer. Theirs, not ours, and a refusal comes back naming the limit. |
| Currency | RWF only. There is no conversion and no second currency. |
| Networks | MTN MoMo (078, 079) and Airtel Money (072, 073). Anything else is a 400. |