Send a batch of transfers
Submit N transfers in one call, consent to the whole batch with a single code, and get a per-item outcome. A loop over the single-transfer behaviour behind one confirmation.
POST /v1/transfers/batch is not a new rail and it is not a new confirmation
model. It is a loop over sending money from your KES
account: every item is
validated exactly like a single POST /v1/transfers, and the whole batch is
covered by one confirmation code instead of one per item.
By default, creating a batch does not move anything
POST /v1/transfers/batch returns 202 with a confirmation — a one-time
code sent to your registered confirmation contact, not to your integration
(unless you have asked us to push codes to your webhook; see Auto-confirm
instructions). Nothing reaches the
bank until that code comes back to us on POST /v1/confirmations/:id. This is
identical to a single transfer; see the consent
model
for the full reasoning. The one exception is an account OnLink has enabled
confirmation waiving for; see When confirmation is
waived.
sequenceDiagram
autonumber
accTitle: Submitting a batch, confirming it once, then executing each item
accDescr: Seven messages between you, OnLink and your registered person. You submit a batch of items. OnLink validates every item, writing a failed outcome for any that do not pass, and refuses the whole batch only if none are valid. OnLink delivers ONE confirmation code to your registered person, covering the whole batch. OnLink answers you 202 with the batch id, every item's outcome so far, and a confirmation carrying its own id and expiry. Your registered person reads you the code over your own channel. You post that code to the confirmation. OnLink answers 200 confirmed, then drives each still-valid item through the same executor a single transfer uses, one at a time. OnLink posts you the usual transfer.settled or transfer.failed webhook for each item as it settles.
participant You
participant OnLink
participant Person as Your registered person
participant Bank
You->>OnLink: POST /v1/transfers/batch (items[])
OnLink->>OnLink: validate every item; write failed outcomes now (B4)
OnLink->>Person: deliver ONE confirmation code for the whole batch
OnLink-->>You: 202 batchId, per-item outcomes so far, confirmation { id, expiresAt }
Person->>You: reads you the code (your own channel)
You->>OnLink: POST /v1/confirmations/:id (code)
OnLink-->>You: 200 status=confirmed
OnLink->>Bank: drive each still-valid item, one at a time
OnLink-->>You: transfer.settled / transfer.failed per item, as usualThe same seven steps, written out:
- You call
POST /v1/transfers/batchwith an array of items, each shaped exactly like a single transfer's create body. - We validate every item through the same path a single
POST /v1/transfersuses. An item that fails is writtenfailedwith its refusal reason right now — before any code is issued. See partial success is the contract below. - We deliver one confirmation code covering the whole batch to your registered confirmation contact — not to your integration, unless you have asked for codes by webhook.
- We answer you
202, carrying the batch'sid, every item's outcome so far, and aconfirmationblock with its ownidandexpiresAt. - Your registered person passes you the code over your own channel, exactly as for a single transfer.
- You call
POST /v1/confirmations/:idwith that code — the identical endpoint a single transfer confirms with; only the confirmation'ssubjectType("batch"instead of"transfer") differs. - Once confirmed, we drive every still-valid item through the same executor a
single transfer uses, one at a time, and post the usual
transfer.settled/transfer.failedwebhook per item as it settles — there is no separate batch-level webhook.
When confirmation is waived
OnLink can enable confirmation waiving for your partner account, on request. When
it is on, POST /v1/transfers/batch creates the batch already confirmed, with
no confirmation object in the response (the key is absent, not null), and
starts executing its items straight away. There is no
code and no POST /v1/confirmations/:id call to make. In the list above, steps 3, 5
and 6 do not happen; step 4 returns confirmed with no confirmation object; and step 7
starts at once. Wait for the per-item webhooks.
- Handle both answers on create. Accept
confirmedandawaiting_confirmation, and only post a code when there is aconfirmationblock to post it to. - In production, the waiver applies only to a key with an IP allowlist. If your waived
account's API key has no IP
allowlist, the batch is not
refused: it gets a code as described on this page, and the
confirmationblock carries"waiverNotApplied": "key_without_ip_allowlist", on the first response only (a repeat of the same request returns the stored batch without it). Ask OnLink to add an IP allowlist to that API key. If your account lacks a deliverable channel for the delivery mode OnLink has configured (a confirmation contact email for contact delivery, or a usable webhook for webhook delivery), there is no code to send: the create is refused with503CONFIRMATION_CONTACT_MISSINGorCONFIRMATION_DELIVERY_UNAVAILABLE, and that503carriesdetails.waiverNotApplied: "key_without_ip_allowlist"when the missing allowlist is the cause. Nothing is created. - Validation still happens at submit, and a batch with zero valid items is still
refused
BATCH_NO_VALID_ITEMS. See the single-transfer guide's When confirmation is waived for the full picture.
Partial success is the contract
A batch is not all-or-nothing. If item 3 of 10 fails a cap check or a name check, items 1, 2 and 4–10 are unaffected — they are still validated, confirmed and executed. The two things worth knowing before you build against this:
- Validation happens at submit, not at confirm. Every item's outcome that
can be known before a code is issued IS known before a code is issued: the
202response already shows you which items were accepted and which failed, and why. You are never asked to consent to a batch whose failures are still a surprise. - A batch with zero valid items is refused outright —
422BATCH_NO_VALID_ITEMS— rather than issuing a code for a batch that can accomplish nothing. Nothing was written and no code was issued, but you are still told why:details.itemslists every item by its ownidempotencyKeywith thefailureCodethat refused it, in the same shape a partially-accepted batch returns. Fix those items and resubmit.
1. Submit the batch
Each item in items is shaped exactly like a single transfer's create
body —
same rail/destination/amount rules, same per-item idempotencyKey. The
batch itself carries its own idempotencyKey too; see Batching transfers:
two idempotency keys
for why both are required.
#!/usr/bin/env bash
# Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./batch-create.sh
set -euo pipefail
HOST="https://sandbox.onlink.africa"
METHOD="POST"
PATH_AND_QUERY="/v1/transfers/batch"
BODY='{"idempotencyKey":"batch-2026-09-16-000001","items":[{"rail":"mpesa","destination":{"accountNumber":"254700000000","accountName":"Jane Wanjiku"},"amount":{"currency":"KES","value":"1500.00"},"idempotencyKey":"trf-2026-09-16-000001"},{"rail":"pesalink","destination":{"accountNumber":"000000000123456","accountName":"John Otieno","bankCode":"11"},"amount":{"currency":"KES","value":"2500.00"},"idempotencyKey":"trf-2026-09-16-000002"}]}'
TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')"
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
# Five fields, newline-joined. printf, not echo -e: the trailing newline echo
# adds would be signed and the signature would not match.
SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \
"$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")"
SIGNATURE="$(printf '%s' "$SIGNING_STRING" \
| openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \
| awk '{print $NF}')"
curl -i -X POST "${HOST}${PATH_AND_QUERY}" \
-H "X-OnLink-Key: ${ONLINK_KEY_ID}" \
-H "X-OnLink-Timestamp: ${TIMESTAMP}" \
-H "X-OnLink-Nonce: ${NONCE}" \
-H "X-OnLink-Signature: v1=${SIGNATURE}" \
-H 'Content-Type: application/json' \
--data "$BODY"| Field | Notes |
|---|---|
idempotencyKey | Required. Unique per partner, for the WHOLE batch. Dedupes the SUBMISSION — see Idempotency. |
items | 1 to 100 items (the declared per-request limit), each shaped exactly like POST /v1/transfers's body, including its OWN idempotencyKey. |
Response — 202 Accepted:
{
"id": "be9f1e6a-0000-4000-8000-000000000040",
"status": "awaiting_confirmation",
"itemCount": 2,
"acceptedCount": 2,
"failedCount": 0,
"items": [
{
"transferId": "bd9f1e6a-0000-4000-8000-000000000010",
"idempotencyKey": "trf-2026-09-16-000001",
"accepted": true
},
{
"transferId": "cd9f1e6a-0000-4000-8000-000000000011",
"idempotencyKey": "trf-2026-09-16-000002",
"accepted": true
}
],
"createdAt": "2026-09-16T00:00:00.000Z",
"confirmation": {
"id": "df9f1e6a-0000-4000-8000-000000000030",
"channel": "email",
"deliveredTo": "o***@partner.co.ke",
"expiresAt": "2026-09-16T00:05:00.000Z",
"resendAvailableAt": "2026-09-16T00:00:15.000Z"
}
}An item that failed validation reports accepted: false and a failureCode
instead of a transferId — one of the same per-item codes a single transfer
can be refused with (TRANSFER_NAME_MISMATCH, TRANSFER_EXCEEDS_DAILY_CAP,
…; see Errors):
{
"transferId": null,
"idempotencyKey": "trf-2026-09-16-000003",
"accepted": false,
"failureCode": "TRANSFER_NAME_MISMATCH"
}2. Confirm the batch
Same endpoint, same shape, as confirming a single transfer — POST /v1/confirmations/:id with the code your registered person read you. See
step 4 of the transfers
guide
for the request/response shape; the only difference is subjectType:
{
"confirmationId": "df9f1e6a-0000-4000-8000-000000000030",
"subjectType": "batch",
"subjectId": "be9f1e6a-0000-4000-8000-000000000040",
"status": "confirmed",
"verifiedAt": "2026-09-16T00:02:00.000Z"
}status: "confirmed" means the code was right and every still-valid item has
been handed off for execution — one at a time, through the same executor a
single transfer uses. It is not a settlement signal for any item; read on.
3. Read the batch, and wait for each item's settlement
GET /v1/transfers/batch/:id returns the batch with every item's outcome, in
the order submitted:
{
"id": "be9f1e6a-0000-4000-8000-000000000040",
"status": "processing",
"itemCount": 2,
"acceptedCount": 1,
"failedCount": 0,
"items": [
{
"transferId": "bd9f1e6a-0000-4000-8000-000000000010",
"idempotencyKey": "trf-2026-09-16-000001",
"accepted": true
},
{
"transferId": "cd9f1e6a-0000-4000-8000-000000000011",
"idempotencyKey": "trf-2026-09-16-000002",
"accepted": true
}
],
"createdAt": "2026-09-16T00:00:00.000Z",
"confirmedAt": "2026-09-16T00:02:00.000Z"
}There is no separate batch webhook. Each item posts the ordinary
transfer.settled / transfer.failed event as it settles, exactly as
described in step 5 of the transfers
guide —
including the same rule that silence is not failure: read the item with
GET /v1/transfers/{id}, never re-instruct it.
acceptedCount / failedCount roll up as items settle; the batch's own
status reaches completed only once every item has a terminal outcome.
Caps accumulate across the whole batch
The rolling 24-hour cap (shared with orders and single transfers — see Caps
and limits) and your live KES balance are
each checked cumulatively across the items, in submission order — not
independently per item. Ten small transfers that would each individually pass
a cap can still cause the batch as a whole to breach it; the items past the
breach are reported failed with the ordinary cap refusal code, and the
items before it are unaffected. An item can also fail
TRANSFER_EXCEEDS_DAILY_CAP when another of your payouts or orders was
created at the same moment and used the remaining cap first.
One currency per batch
A batch can pay KES or USD (see Paying out
USD), but not both:
it is paid from one account under one code for one total. Every item's
amount.currency must be the same, or the whole batch is refused 422
BATCH_CURRENCY_MIXED (with details.currencies) before anything is written or a
code is issued; submit one batch per currency. A USD batch is checked against your
USD balance, its code authorises the USD total, and each USD item counts against
your KES daily cap at its KES equivalent. A USD item on a rail that does not take
USD (M-PESA, PesaLink, on-us) is reported failed with
TRANSFER_RAIL_CURRENCY_UNSUPPORTED, like any other per-item refusal. USD batches
are for Collections accounts: a USDT Rails account's USD batch is refused whole,
422 TRANSFER_CURRENCY_NOT_ENABLED, before anything is written.
Errors worth handling
Every code in this section's shape is catalogued in Errors — Batch transfers. The ones worth highlighting here:
| Status | Meaning |
|---|---|
422 | BATCH_NO_VALID_ITEMS — every item failed validation; nothing was written and no code was issued. details.items names each item and its failureCode. |
422 | BATCH_TOO_LARGE — the batch carries more items than the declared per-request limit. Split it into smaller batches. |
422 | BATCH_CURRENCY_MIXED — the items send more than one currency. Submit one batch per currency. |
422 | TRANSFER_CURRENCY_NOT_ENABLED — a USD batch from a USDT Rails account, which sends KES only. Send the batch in KES. |
Send money from your KES account
Create an instruction, your registered person confirms it with a one-time code, then it executes. Six rails, one consent step, one honest failure model.
Auto-confirm instructions
Have confirmation codes pushed to your signed webhook and confirm an instruction from your own code: verify, dedupe, match, confirm. What changes, what to check, and what to do when a push does not arrive.