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_keyrequired8–128 characters. Yours. Send it again and you get the original payout back, not a second one.
amount_minorrequiredMinor units, greater than zero. 150,000 RWF is 15000000.
currencyrequiredThree letters. RWF, UGX, EUR.
methodrequiredmobile_money, bank, card or bill.
transaction_typerequiredb2p 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.

Rwanda — RWF to an MTN wallet
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":

Uganda — UGX to an Airtel wallet
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.

Rwanda — RWF to a bank 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": "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:

Any corridor — paying a saved beneficiary
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.

MethodKeys the rails read
mobile_moneyphone_number, network — MTN or Airtel
bankaccount_number, bank_name, and bank_code where the corridor uses one
billDon'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.

CorridorCurrencyCountries
RwandaRWFRW
UgandaUGXUG
Euro areaEURAT 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.

201 Created
{
  "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

StatusWhat it means
createdRaised, funds reserved.
requires_approvalYour own thresholds want a signature. approval_id says which request.
parked_for_reviewWe are holding it — a compliance control, not your threshold.
approvedSigned, about to go.
processingWith a rail.
completedPaid.
failedTerminal. The reservation has been released, so the money is spendable again.
returnedIt 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.

Whatever is waiting on us
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.

StatusWhat happenedWhat to do
400The request cannot be a payout — a non-positive amount.Fix the request.
402Your balance will not cover amount_minor + fee_minor.Top up, or send less.
409The beneficiary is blocked, archived, or has not cleared screening.See beneficiaries.
422A limit refused it. The message names the limit.Read /payouts/limits, or send less.
503No 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:

A payout that declines
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

On this page