OnLink
Guides

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 usual

The same seven steps, written out:

  1. You call POST /v1/transfers/batch with an array of items, each shaped exactly like a single transfer's create body.
  2. We validate every item through the same path a single POST /v1/transfers uses. An item that fails is written failed with its refusal reason right now — before any code is issued. See partial success is the contract below.
  3. We deliver one confirmation code covering the whole batch to your registered confirmation contact — never to your integration.
  4. We answer you 202, carrying the batch's id, every item's outcome so far, and a confirmation block with its own id and expiresAt.
  5. Your registered person passes you the code over your own channel, exactly as for a single transfer.
  6. You call POST /v1/confirmations/:id with that code — the identical endpoint a single transfer confirms with; only the confirmation's subjectType ("batch" instead of "transfer") differs.
  7. 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.failed webhook 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 202 response 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 — 422 BATCH_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.items lists every item by its own idempotencyKey with the failureCode that 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"
FieldNotes
idempotencyKeyRequired. Unique per partner, for the WHOLE batch. Dedupes the SUBMISSION — see Idempotency.
items1 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:

StatusMeaning
422BATCH_NO_VALID_ITEMS — every item failed validation; nothing was written and no code was issued. details.items names each item and its failureCode.
422BATCH_TOO_LARGE — the batch carries more items than the declared per-request limit. Split it into smaller batches.

On this page