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_minorrequiredWhole minor units, positive.
methodrequiredmobile_money, bank, card, bill.
transaction_typerequiredb2p or b2b.
beneficiary_ideither thisA saved recipient. Fills in the name and country.
country_codeor thisNeeded to choose a rail when there is no beneficiary.
recipient_nameoptionalTaken from the beneficiary when there is one.
fee_minorleave emptyInversePay prices each line when the file is checked. A line naming a fee is refused.
currencyoptionalMust match the batch. One batch pays in one currency.
referenceoptionalYours.

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"
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-0043

Every reason a row is unusable, at once

201 Created
{
  "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
validatingBeing checked.
requires_approvalYour thresholds want a signature.
readySigned, or below every threshold. Runnable.
processingRunning.
completedEvery valid line went out.
completed_with_errorsSome line failed, or the file had invalid rows.
cancelledWithdrawn 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
cadencedaily, weekly or monthly.
day_of_weekWeekly only. 0 is Monday.
day_of_monthMonthly only. Clamped in shorter months, so 31 pays on the 30th in April.
run_hour / run_minuteDefaults 9 and 0.
timezoneIANA 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_atOptional.

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.

Add an employee
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.

Run a period
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.

Then what

On this page