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.

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 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 — not to your integration, unless you have asked for codes by webhook.
  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.

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 confirmed and awaiting_confirmation, and only post a code when there is a confirmation block 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 confirmation block 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 with 503 CONFIRMATION_CONTACT_MISSING or CONFIRMATION_DELIVERY_UNAVAILABLE, and that 503 carries details.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 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":"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"
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. 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:

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.
422BATCH_CURRENCY_MIXED — the items send more than one currency. Submit one batch per currency.
422TRANSFER_CURRENCY_NOT_ENABLED — a USD batch from a USDT Rails account, which sends KES only. Send the batch in KES.

On this page