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.
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.
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.
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 — never to your integration.
- 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.
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":"000000000000","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.
Errors worth handling
Every code in this section's shape is catalogued in Errors — Batch transfers. The two 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. |
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.
Send USDT from your wallet
Create an instruction, your registered person confirms it with a one-time code, then it executes. One rail, one consent step, one honest failure model.