Pay somebody
Send money to a mobile money wallet, a bank account or a biller.
A payout moves money out of your balance. You say how much, in what currency, by what method, and to whom. We pick the rail from the corridor — you never name a partner, and a partner change on our side is never a release on yours.
Payouts cannot be created with an API key. Raising one is a person's act and
needs a person accountable for it, so creation is a dashboard session only. A
key with payouts:read can read payouts, batches and schedules. This is
deliberate — see Authentication.
The five fields every payout has
| Field | ||
|---|---|---|
idempotency_key | required | 8–128 characters. Yours. Send it again and you get the original payout back, not a second one. |
amount_minor | required | Minor units, greater than zero. 150,000 RWF is 15000000. |
currency | required | Three letters. RWF, UGX, EUR. |
method | required | mobile_money, bank, card or bill. |
transaction_type | required | b2p paying a person, b2b paying a business. |
transaction_type is never inferred. It is a different product to the partner
and a different line in a regulator's report, so guessing it would be guessing
at money.
Everything else is optional: recipient_name, country_code, reference (up
to 128 characters, yours), and one of beneficiary_id or destination to say
where the money goes. Leave fee_minor out: InversePay sets the fee from your
plan and returns it. See Fees.
Mobile money
The commonest payout in both African corridors. Give the wallet's number and the network it is on.
curl -X POST https://sandbox.inversepay.app/api/b/v1/payouts \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "payroll-2026-08-0042",
"amount_minor": 15000000,
"currency": "RWF",
"method": "mobile_money",
"transaction_type": "b2p",
"recipient_name": "Aline Uwase",
"country_code": "RW",
"reference": "AUG-SALARY-0042",
"destination": {
"phone_number": "+250788123456",
"network": "MTN"
}
}'150,000 RWF to a Rwandan mobile-money wallet. Uganda is the same request with
"currency": "UGX" and "country_code": "UG":
curl -X POST https://sandbox.inversepay.app/api/b/v1/payouts \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "supplier-2026-08-0114",
"amount_minor": 45000000,
"currency": "UGX",
"method": "mobile_money",
"transaction_type": "b2b",
"recipient_name": "Kampala Fresh Produce Ltd",
"country_code": "UG",
"destination": {
"phone_number": "+256772123456",
"network": "Airtel"
}
}'Note the transaction_type changed with the recipient, not with the country.
Bank accounts
A bank payout is the same call with "method": "bank" and account details
instead of a wallet number.
curl -X POST https://sandbox.inversepay.app/api/b/v1/payouts \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "invoice-8821-settlement",
"amount_minor": 240000000,
"currency": "RWF",
"method": "bank",
"transaction_type": "b2b",
"recipient_name": "Rwanda Metal Works Ltd",
"country_code": "RW",
"reference": "INV-8821",
"destination": {
"account_number": "00040100123456789",
"bank_name": "Banque de Kigali"
}
}'For a bank account, save a beneficiary instead. destination is passed
through as you wrote it — nothing checks that the account exists or that the
name on it is the name you typed. A saved beneficiary is
name-checked and screened before any money is committed to it, and a payout to
one that has not cleared is refused rather than sent. On a wallet a wrong digit
usually bounces; on a bank account it usually arrives, at somebody else's.
Paying a saved recipient is shorter, because the beneficiary already carries the name, country and account:
curl -X POST https://sandbox.inversepay.app/api/b/v1/payouts \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "invoice-8821-settlement",
"amount_minor": 240000000,
"currency": "EUR",
"method": "bank",
"transaction_type": "b2b",
"beneficiary_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}'Anything you also send wins over the beneficiary's own values; anything you omit is filled in from it.
What destination is, exactly
destination is an open object. It is stored with the payout and carried to the
rail that ends up serving it. The API does not validate its shape, so the
keys below are the ones our own rails read, not a schema you can rely on being
checked for you.
| Method | Keys the rails read |
|---|---|
mobile_money | phone_number, network — MTN or Airtel |
bank | account_number, bank_name, and bank_code where the corridor uses one |
bill | Don't hand-write one — use bill payments, which builds it |
This is the trade the endpoint makes: an open field takes any corridor's account shape without a schema change, and in exchange nothing catches your typo. If you want it caught, save a beneficiary.
Corridors
The corridor comes from country_code first, then from currency, then from the
destination's dialling prefix. In practice "currency": "RWF" is enough to route
a Rwandan payout, and country_code matters when the currency does not identify
the country on its own.
| Corridor | Currency | Countries |
|---|---|---|
| Rwanda | RWF | RW |
| Uganda | UGX | UG |
| Euro area | EUR | AT BE CY DE EE ES FI FR GR IE IT LT LU LV MT NL PT SI SK |
If no rail can carry the payment — because we do not serve the corridor at all,
or because every rail that does is out of service — the answer is a 503 and
the message says which. Nothing is reserved and no payout row is written, so
retrying with the same idempotency key is safe.
The money is committed before the response
The funds are reserved before the call returns, whether or not the payout needs a
signature. A requires_approval response means the money is held and somebody
must sign, not nothing has happened yet.
That matters when you retry. If you treat a pending approval as "not raised" and send again, the idempotency key is what saves you from paying twice.
{
"id": "9d1b...",
"reference": "AUG-SALARY-0042",
"amount_minor": 15000000,
"fee_minor": 30000,
"total_minor": 15030000,
"currency": "RWF",
"status": "processing",
"method": "mobile_money",
"transaction_type": "b2p",
"recipient_name": "Aline Uwase",
"country_code": "RW",
"rail": "xentripay",
"provider_reference": null,
"failure_reason": null,
"approval_id": null,
"created_at": "2026-08-14T09:12:44Z",
"completed_at": null
}total_minor is amount_minor + fee_minor — what leaves your balance. The
fee is InversePay's, added on top, so the recipient receives amount_minor in
full. The fee here is made up; GET /payouts/fee quotes yours. See
Fees.
rail is the partner chosen for this payment. It is in the response for support
and reconciliation, not for you to branch on: which rail serves a corridor is
ours to change, it can change mid-flight when one partner cannot take a payment,
and the same request tomorrow may come back with a different name in it.
provider_reference is that partner's own reference, and fills in once they have
one.
Statuses
| Status | What it means |
|---|---|
created | Raised, funds reserved. |
requires_approval | Your own thresholds want a signature. approval_id says which request. |
parked_for_review | We are holding it — a compliance control, not your threshold. |
approved | Signed, about to go. |
processing | With a rail. |
completed | Paid. |
failed | Terminal. The reservation has been released, so the money is spendable again. |
returned | It was paid and the rail sent it back. |
requires_approval and parked_for_review are not the same thing and collapsing
them would be a mistake: a threshold approval is you deciding you are happy to
send your own money, and a park is us deciding we are not yet happy to move it.
Signing cannot clear a park. Releasing a park does not sign the spend — a
released payout rejoins its normal path and can land straight back in
requires_approval.
curl https://sandbox.inversepay.app/api/b/v1/payouts/parked \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN"Cancelling
A payout that has not reached a rail can be withdrawn. The reservation is released, so the money is spendable again immediately.
curl -X POST https://sandbox.inversepay.app/api/b/v1/payouts/$PAYOUT_ID/cancel \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN"Watching it land
curl https://sandbox.inversepay.app/api/b/v1/payouts/$PAYOUT_ID \
-H "Authorization: Bearer $INVERSEPAY_KEY"Reading a payout only needs payouts:read, so this is the half of the flow your
integration can own.
Read it back with a live key, not a test one. Test and live never see each
other's rows, and a payout raised by a dashboard session is a live row — a
session carries no mode, and the only safe default for an unmarked credential is
the one where nothing is simulated. A sk_test_ key asking for that payout gets
the same 404 it would for a payout that does not exist. See
Test and live below.
For why a payout is where it is — every transition, in the order it happened, with who caused each — there is a timeline:
curl https://sandbox.inversepay.app/api/b/v1/payouts/$PAYOUT_ID/timeline \
-H "Authorization: Bearer $INVERSEPAY_KEY"{
"payout_id": "9d1b...",
"status": "completed",
"entries": [
{ "event_type": "payout.created", "at": "2026-08-14T09:12:44Z", "actor_kind": "member", "actor_id": "3a2f..." },
{ "event_type": "payout.processing", "at": "2026-08-14T09:12:44Z", "actor_kind": "system" },
{ "event_type": "payout.completed", "at": "2026-08-14T09:13:02Z", "actor_kind": "system" }
]
}actor_kind is member or system and is never absent — the platform acting is
a real answer, not a missing one. The same transitions are delivered as
webhooks, which is how you stop polling.
When it does not work
Every one of these is checked before a row exists or money is reserved, so a refused payout leaves nothing behind to clean up.
| Status | What happened | What to do |
|---|---|---|
400 | The request cannot be a payout — a non-positive amount. | Fix the request. |
402 | Your balance will not cover amount_minor + fee_minor. | Top up, or send less. |
409 | The beneficiary is blocked, archived, or has not cleared screening. | See beneficiaries. |
422 | A limit refused it. The message names the limit. | Read /payouts/limits, or send less. |
503 | No rail can carry it: either we do not serve the corridor, or every rail that does is out of service. The message says which. | Read the message. If it is a corridor we serve, retry with the same idempotency key. |
Limits are ours, not yours — you cannot set them, and being refused by a limit nobody will show you is not a control, it is an outage:
curl "https://sandbox.inversepay.app/api/b/v1/payouts/limits?currency=RWF" \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN"A payout that reaches a rail and fails carries a failure_reason from one
normalised vocabulary — insufficient_funds, invalid_account,
cancelled_by_payer, timeout, rejected_by_provider, account_unavailable,
unknown. Branch on that, never on the message.
Test and live
Mode comes from the credential and never from the request. There is no header that makes a live payment simulated, because that header would also be a way to have a real payout treated as a test.
An API key carries a mode — sk_test_ is test, sk_live_ is live. A dashboard
session carries none, and a credential with no mode is live.
Live
Raising real money is a person's act. POST /payouts in live mode needs a
dashboard session, and no API key can do it however it is scoped — which is the
point: a payout that moved real money should be attributable to somebody.
A payout raised that way is a live row, so read it back with a credential that
can see it: a live key with payouts:read, or the session itself. A sk_test_
key asking for it gets a 404.
Test
A sk_test_ key holding payouts:write can create a payout. That is the
only way to raise one without a person, and it exists so you can rehearse the
failures before you ship.
A test payout settles as it is raised — there is no rail to wait for, so the outcome is already in the response. What that outcome is comes from the magic amounts and destinations:
curl -X POST https://sandbox.inversepay.app/api/b/v1/payouts \
-H "Authorization: Bearer $INVERSEPAY_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "rehearsal-1",
"amount_minor": 999999,
"currency": "RWF",
"method": "mobile_money",
"transaction_type": "b2p",
"country_code": "RW",
"destination": { "phone_number": "+250788123456", "network": "MTN" }
}'999999 declines, 999998 times out, 999997 returns after completing, and
999996 holds for review. A magic destination beats an ordinary amount, which is
what you want when the amount is not yours to choose.
Test and live never see each other's rows, in either direction, and no test payout touches a balance. A sandbox that needed real funding before it would simulate a decline would be no use to anybody.
Then what
Save a recipient
The checked way to pay a bank account, and the only way to pay one twice.
Pay many people
Batches, recurring schedules and payroll runs.
Pay a bill
Electricity, airtime, pay TV and tax, with the account checked first.
Payouts reference
Every field and every response, generated from the running API.