Pay many people
Batches, recurring schedules and payroll runs — validated before a shilling moves.
Three ways to pay more than one person, all built on the same payout: a batch is a file you run once, a schedule is a batch that runs itself, and a payroll run is a schedule with employees behind it.
All three are a dashboard session for anything that moves money. A key with
payouts:read can read batches and schedules; it cannot create, approve or run
one. See Authentication.
A batch
Nothing is paid when you create a batch. Every row is validated and the response tells you what cannot run, so a bad file is fixed before any money is committed.
curl -X POST https://sandbox.inversepay.app/api/b/v1/payout-batches \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "August salaries",
"currency": "RWF",
"rows": [
{
"amount_minor": "15000000",
"method": "mobile_money",
"transaction_type": "b2p",
"beneficiary_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"reference": "AUG-0042"
},
{
"amount_minor": "9000000",
"method": "bank",
"transaction_type": "b2p",
"recipient_name": "Jean-Paul Habimana",
"country_code": "RW",
"reference": "AUG-0043"
}
]
}'The columns
The same set whether you post rows or upload a file.
| Column | ||
|---|---|---|
amount_minor | required | Whole minor units, positive. |
method | required | mobile_money, bank, card, bill. |
transaction_type | required | b2p or b2b. |
beneficiary_id | either this | A saved recipient. Fills in the name and country. |
country_code | or this | Needed to choose a rail when there is no beneficiary. |
recipient_name | optional | Taken from the beneficiary when there is one. |
fee_minor | leave empty | InversePay prices each line when the file is checked. A line naming a fee is refused. |
currency | optional | Must match the batch. One batch pays in one currency. |
reference | optional | Yours. |
A CSV is the same columns as a header row:
curl -X POST https://sandbox.inversepay.app/api/b/v1/payout-batches/upload \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-F "file=@august-salaries.csv"amount_minor,method,transaction_type,beneficiary_id,recipient_name,country_code,reference
15000000,mobile_money,b2p,7c9e6679-7425-40de-944b-e07fc1f90ae7,,,AUG-0042
9000000,bank,b2p,,Jean-Paul Habimana,RW,AUG-0043Every reason a row is unusable, at once
{
"batch": {
"id": "4b2d...",
"name": "August salaries",
"source": "api",
"currency": "RWF",
"status": "requires_approval",
"total_lines": 120,
"valid_lines": 118,
"invalid_lines": 2,
"total_minor": 1446000000,
"approval_id": "a91c...",
"created_at": "2026-08-24T14:02:00Z"
},
"line_errors": [
{
"line_number": 57,
"errors": [
{ "field": "transaction_type", "message": "Must be b2p or b2b. ..." },
{ "field": "country_code", "message": "Needed to choose a rail when there is no saved beneficiary." }
]
}
]
}A row collects every problem rather than stopping at the first, so one pass
over the error report fixes the file. total_minor counts only the valid lines —
it is what the run will actually cost.
Screening is checked here as well as at payout time. A blocked recipient found during validation is one line in an error report; found mid-run it is a stopped payroll.
Approve, then run
| Status | |
|---|---|
validating | Being checked. |
requires_approval | Your thresholds want a signature. |
ready | Signed, or below every threshold. Runnable. |
processing | Running. |
completed | Every valid line went out. |
completed_with_errors | Some line failed, or the file had invalid rows. |
cancelled | Withdrawn before it ran. |
curl -X POST https://sandbox.inversepay.app/api/b/v1/payout-batches/$BATCH_ID/approve \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN"
curl -X POST https://sandbox.inversepay.app/api/b/v1/payout-batches/$BATCH_ID/run \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN"The whole batch is checked against your balance twice — when it is approved and again at the moment it runs. Without the second check a run stops paying somewhere in the middle and somebody has to work out who was paid from a list of five hundred.
Running twice is safe. Each line's idempotency key is derived from the batch and the line number, so a second run finds the payouts the first one made rather than making them again. Lines already resolved are skipped without touching a rail — which is what makes "did that finish?" a question you can answer by running it again.
A schedule
A schedule is a batch that runs itself. The lines are the same rows.
curl -X POST https://sandbox.inversepay.app/api/b/v1/payout-schedules \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Monthly retainers",
"currency": "RWF",
"cadence": "monthly",
"day_of_month": 25,
"run_hour": 9,
"timezone": "Africa/Kigali",
"lines": [
{
"amount_minor": "15000000",
"method": "mobile_money",
"transaction_type": "b2p",
"beneficiary_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
]
}'| Field | |
|---|---|
cadence | daily, weekly or monthly. |
day_of_week | Weekly only. 0 is Monday. |
day_of_month | Monthly only. Clamped in shorter months, so 31 pays on the 30th in April. |
run_hour / run_minute | Defaults 9 and 0. |
timezone | IANA name, default UTC. The run time means this zone's clock, so a payroll on the 25th is the 25th where the business is. |
ends_at | Optional. |
Nothing is validated against a rail when you create a schedule. Each occurrence materialises as a batch and is validated then — so a beneficiary blocked after the schedule was written is caught on the run rather than paid on stale information.
A schedule is active, paused or ended, and there are endpoints for each
transition. Runs that could not go ahead — usually because the balance would not
cover them — are readable:
curl https://sandbox.inversepay.app/api/b/v1/payout-schedules/held-runs \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN"Each held run says what it needed and what was there: required_minor against
available_minor.
Payroll
Payroll is people rather than rows. An employee is saved once, with the account they are paid into, and every run pays the current list.
curl -X POST https://sandbox.inversepay.app/api/b/v1/payroll/employees \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"employee_number": "EMP-0042",
"full_name": "Aline Uwase",
"net_pay_minor": 15000000,
"currency": "RWF",
"payout_method": "mobile_money",
"employment_type": "full_time",
"beneficiary_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"country_code": "RW",
"department": "Engineering"
}'net_pay_minor is net — what the employee receives. Nothing here computes
tax or deductions from a gross figure, and treating it as gross underpays
everybody on your list.
payout_method is mobile_money or bank, and it decides the rail per
employee: people on wallets and people on bank accounts go out over different
partners in the same run, each routed on its own merits.
A beneficiary_id is preferred over a raw account_identifier — it is the one
that has been name-checked and screened.
curl -X POST https://sandbox.inversepay.app/api/b/v1/payroll/runs \
-H "Authorization: Bearer $INVERSEPAY_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "period": "2026-08", "currency": "RWF" }'A run is draft, awaiting_approval, approved, completed,
completed_with_errors or cancelled.
Payroll always needs a signature, at any amount. It is not subject to your ordinary approval thresholds — a run below every threshold still waits for a person.