Refunds
Sending a customer their money back, in full or in part, with the two transactions permanently linked.
The endpoint
/v1/payments/:id/refundApp keycurl -X POST https://api.xendlypay.com/v1/payments/$ID/refund \
-H "Authorization: Bearer $XENDLY_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Order cancelled" }'| Field | Required | Notes |
|---|---|---|
amount | no | Minor units. Leave it out to refund the whole payment, which is the common case and the one people mistype when forced to restate it. |
reason | no | Kept on the transaction and shown in your dashboard. |
A refund is a real payout
It is not a flag on the original payment. It is a new transaction, in the cash-out direction, going to the number that paid you, with reverses_payment_id naming the collection it undoes.
{
"success": true,
"payload": {
"id": "b4d9…",
"direction": "payout",
"status": "pending",
"amount": 20000,
"msisdn": "250788924941",
"reverses_payment_id": "0f8c2b7e-1a45-4c31-9d02-6e8f1b2c3d44",
"description": "Refund: Order cancelled"
},
"timestamp": "2026-09-12T11:02:14.000Z"
}Which means it behaves like a payout in every way that matters: it takes a few seconds, it settles asynchronously, it emits payout.succeeded, and it comes out of your available balance. The customer is not prompted; money simply arrives.
What can be refunded
| Condition | Result |
|---|---|
| A succeeded cash in | Refundable |
| A pending cash in | Refused, 400. Nobody has paid yet; cancel it instead by letting the prompt expire. |
| A failed or cancelled payment | Refused, 400. There is nothing to give back. |
| A payout | Refused, 400. Send a new payout if you want money to move the other way. |
| A payment already refunded | Refused, 409. |
| More than the original amount | Refused, 400. |
One refund per payment
The fee
The collection fee is not returned. We paid the rails to move that money in, and refunding it means paying them again to move it back out. What the customer receives is the full amount they paid; what it costs you is the original fee.
This is worth saying plainly because it shapes how you use refunds: on a 20,000 RWF payment refunded in full, the customer gets 20,000 back and you are out the fee on the way in.
From the dashboard
Every refundable payment has a Refund button on its detail page in Payments. Same endpoint, same rules, same ledger entries. It is recorded as initiated_by: dashboard so you can tell later whether a refund came from your code or from a person.
What we have not built yet
Being straight about the edges, so you can plan around them:
- Multiple partial refunds against one payment. One refund, once.
- Refund to a different number than the one that paid. Deliberate: it is the shape most refund fraud takes.
- Automatic reversal of a refund that fails to land. A failed refund stays visible as a failed payout for you to retry.