OnLink
Guides

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.

This moves value out of your KES account to M-PESA, PesaLink, RTGS, EFT, SWIFT or another onlink KES account. It is a different shape from buying or selling USDT: there is no counterparty payment to wait for, but there is a consent step, and what happens after that step is the part worth reading carefully before you integrate.

Creating a transfer does not move anything

POST /v1/transfers 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.

sequenceDiagram
    autonumber
    accTitle: Instructing a transfer, confirming it with a code, then settling it
    accDescr: Eight messages between you, OnLink, your registered person and the bank. You create a transfer, naming the rail, the destination and the amount. OnLink verifies the beneficiary name and checks your caps and balance. OnLink delivers the confirmation code to your registered person. OnLink answers you 202 with the transfer id and a confirmation carrying its 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 dispatches to the bank and waits for the bank to answer. OnLink posts you either the transfer.settled or the transfer.failed webhook.
    participant You
    participant OnLink
    participant Person as Your registered person
    participant Bank

    You->>OnLink: POST /v1/transfers (rail, destination, amount)
    OnLink->>OnLink: verify beneficiary name, check caps and balance
    OnLink->>Person: deliver the confirmation code
    OnLink-->>You: 202 transferId, confirmation { id, expiresAt }
    Person->>You: reads you the code (your own channel — a call, a chat, a desk)
    You->>OnLink: POST /v1/confirmations/:id (code)
    OnLink-->>You: 200 status=confirmed
    OnLink->>Bank: dispatch, wait for the bank to answer
    OnLink-->>You: webhook transfer.settled or transfer.failed

The same eight steps, written out — everything above is here, so nothing in this guide depends on seeing the picture:

  1. You call POST /v1/transfers with the rail, the destination and the amount.
  2. We verify the beneficiary name against the rail, and check the transfer against your caps and your KES balance.
  3. We deliver the one-time confirmation code to your registered confirmation contact — never to your integration.
  4. We answer you 202, carrying the transfer's id and a confirmation block with its own id and expiresAt.
  5. Your registered person passes you the code over your own channel — a call, a chat, an operator desk. We are not in that step.
  6. You call POST /v1/confirmations/:id with that code.
  7. We answer 200 with status=confirmed, and only then dispatch to the bank and wait for the bank to answer.
  8. We post you one of two webhooks: transfer.settled if the bank completed it, or transfer.failed if the bank answered and refused it.

A transfer is instructed by your integration and confirmed by your registered confirmation contact. Those are two different acts, and the API keeps them separate on purpose:

  • Creating a transfer writes the instruction and sends a 6-digit code to the confirmation contact registered on your partner account — an email address or phone number you gave us, not an API field you can set per request.
  • We never send that code to your integration. It has to reach whoever holds it through whatever channel they read it from — read aloud on a call, typed into your operator console, however your organisation gets a code from that contact to a keyboard. That gap is deliberate: the code is what proves your registered contact looked at this specific instruction before it becomes real money movement.
  • Only POST /v1/confirmations/:id with that code moves the transfer forward. Before that call lands, nothing has reached the bank — not a hold, not a provisional debit, nothing. The instruction sits at awaiting_confirmation and can be left there, resent, or it lapses on its own after the code's window passes.

This is not friction for its own sake. It is what caps how much an integration bug, a leaked key, or a compromised script can move: at most one instruction, and only once someone with access to the confirmation contact has read a code off it.

1. Choose a rail and its destination

Every rail validates its own destination shape. accountName is always required; the fields below it are rail-specific, and a field a rail does not use is refused rather than silently ignored.

RailaccountNumberAlso requiredNot allowed
mpesaA Kenyan mobile number, any recognised format — normalised server-side.—bankCode, bankName, branchCode, swift, paymentPurposeId, senderAddress
pesalink6–20 digits.bankCodebankName, branchCode, swift, paymentPurposeId, senderAddress
rtgs6–34 alphanumeric characters.bankCode, bankName, paymentPurposeId, senderAddress. branchCode optional.swift
eft6–34 alphanumeric characters.Same as rtgs.swift
swift6–34 alphanumeric characters.bankName, paymentPurposeId, senderAddress, and a swift object: bic, bankCity, bankCountry (spelled out in full — never an ISO code).bankCode, branchCode
internal6–20 digits — another onlink KES account.—bankCode, bankName, branchCode, swift, paymentPurposeId, senderAddress

A field in the wrong shape, or a field your rail does not use, comes back as 422 TRANSFER_DESTINATION_INVALID naming the exact field. See Errors for the full table.

2. Create the transfer

#!/usr/bin/env bash
# Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./transfer-create.sh
set -euo pipefail

HOST="https://sandbox.onlink.africa"
METHOD="POST"
PATH_AND_QUERY="/v1/transfers"
BODY='{"rail":"mpesa","destination":{"accountNumber":"000000000000","accountName":"Jane Wanjiku"},"amount":{"currency":"KES","value":"1500.00"},"partnerReference":"PS-TRF-000123","idempotencyKey":"trf-2026-09-11-000123"}'

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}')"

# --data sends the SAME bytes that were hashed. Piping the body through a tool
# that reformats it, or adds a trailing newline, changes the hash and the
# signature stops matching — with a bare 401 and no reason given.
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
railOne of mpesa, pesalink, rtgs, eft, swift, internal.
destinationPer the table above.
amount{ currency: "KES", value: "<decimal string>" }. Greater than zero, at most 2 decimal places.
partnerReferenceYour own handle, echoed back on every read. Optional, but there is no list-transfers endpoint, so record one.
idempotencyKeyRequired. See Idempotency for the replay rule.

Response — 202 Accepted:

{
  "id": "bd9f1e6a-0000-4000-8000-000000000010",
  "status": "awaiting_confirmation",
  "rail": "mpesa",
  "amount": { "currency": "KES", "value": "1500.00" },
  "fee": { "currency": "KES", "value": "10.00" },
  "destination": {
    "accountNumber": "000000000000",
    "accountName": "Jane Wanjiku"
  },
  "nameVerification": "verified",
  "verifiedAccountName": "JANE WANJIKU",
  "partnerReference": "PS-TRF-000123",
  "createdAt": "2026-09-11T00:00:00.000Z",
  "confirmation": {
    "id": "df9f1e6a-0000-4000-8000-000000000030",
    "channel": "email",
    "deliveredTo": "o***@partner.co.ke",
    "expiresAt": "2026-09-11T00:05:00.000Z",
    "resendAvailableAt": "2026-09-11T00:00:15.000Z"
  }
}

Beneficiary name verification

nameVerification tells you whether the accountName you sent could be checked against the rail:

  • verified — the name matched. verifiedAccountName carries the resolved name, which may differ in case or punctuation from what you sent.
  • unavailable — no confirmation either way. This covers two different situations behind one value: rtgs, eft and swift have no name resolver at all, so they are always unavailable; on mpesa, pesalink and internal, unavailable instead means the check could not complete right now (a transient provider condition). Either way, the transfer still proceeds — a name you cannot verify is not treated as a name you got wrong.

A name that resolves to something different from what you sent is not unavailable — it is refused outright, before a confirmation is ever issued: 422 TRANSFER_NAME_MISMATCH, carrying the resolved name in details.resolvedName. Confirm the name with your payee and retry only if you are sure; do not retry the same mismatched pair unchanged.

3. The code reaches your registered person

The confirmation block on the response names where the code went (channel, a masked deliveredTo) and when it expires. Nothing about the code itself is in that response, and nothing about it is ever in a webhook — the only way to see whether it was right is to submit it and read the result.

If the code has not arrived, POST /v1/confirmations/:id/resend issues a new one on a cooldown ladder — see Caps and limits. Resending invalidates the previous code.

4. Confirm the transfer

#!/usr/bin/env bash
# Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./confirmation-verify.sh
# Replace the confirmation id in PATH_AND_QUERY with the "confirmation.id"
# from your own create response.
set -euo pipefail

HOST="https://sandbox.onlink.africa"
METHOD="POST"
PATH_AND_QUERY="/v1/confirmations/df9f1e6a-0000-4000-8000-000000000030"
BODY='{"code":"482913"}'

TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')"
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"

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"

Response — 200 OK:

{
  "confirmationId": "df9f1e6a-0000-4000-8000-000000000030",
  "subjectType": "transfer",
  "subjectId": "bd9f1e6a-0000-4000-8000-000000000010",
  "status": "confirmed",
  "verifiedAt": "2026-09-11T00:02:00.000Z"
}

status: "confirmed" is what this call means: the code was right, and execution has been handed off. It is not a settlement signal — read on.

5. Wait for settlement — and read the failure model carefully

Once confirmed, a transfer moves through executing and processing while we dispatch it, and ends at settled or failed. Two webhooks, and the distinction between them is the part of this guide worth re-reading:

  • transfer.settled — the bank completed it. Value moved.
  • transfer.failed — the bank answered and refused it. Value never moved, and failureCode on the transfer names why. See Errors for what each code means.

Silence is not failure. Read the transfer; do not send it again.

If a confirmed transfer has not settled and has not failed, that is not an unknown outcome we are hiding from you — it is an instruction we cannot yet resolve, and we will not tell you it failed while it may still be settling. We would rather leave it open than tell you something false.

The correct response to that silence is to read the transfer with GET /v1/transfers/{id}, never to instruct a second one. A partner who re-instructs on uncertainty is the exact failure this consent step and this webhook design exist to prevent: two instructions confirmed against the same underlying payment is a double payment, and unlike a duplicate POST with the same idempotencyKey (which safely replays), a second transfer is a new instruction with a new confirmation and nothing stops it from executing alongside the first.

There is also a third event, keyed on the confirmation rather than the transfer:

  • confirmation.expired — the code's window passed before anyone confirmed it. The transfer itself moves to expired; nothing was ever sent to the bank. Create a new transfer if the payment is still wanted.

Statuses you will see on GET /v1/transfers/{id}

statusMeaningWebhook
awaiting_confirmationWaiting on the code. Nothing sent to the bank yet.none — you already have the 202
confirmedCode verified; handed off for execution.none — you already have the 200 confirm response
executingBeing dispatched. May also mean an earlier attempt could not be resolved — see the callout above.none
processingThe bank accepted it and is completing it.none
settledDone. Value moved.transfer.settled
failedThe bank answered and refused it.transfer.failed
expiredThe confirmation code's window passed unconfirmed.confirmation.expired
cancelledWithdrawn before confirmation.none — nothing was ever asked of the bank

Caps and balance

Transfers count against the same rolling 24-hour cap as your orders — one ceiling across both, not two independent ones. See Caps and limits.

Passing the balance check at creation does not reserve the amount. Nothing is held aside between creation and confirmation, or between confirmation and execution. If you have more than one transfer or order in flight, make sure your KES account can cover all of them at once — a later one can fail on TRANSFER_INSUFFICIENT_BALANCE even though an earlier check on the same account passed.

Why nothing reaches the bank before the code

This is stated for your benefit, not ours: the confirmation step is what limits the blast radius of a mistake on your side — a bug that fires POST /v1/transfers in a loop, a leaked key, a bad retry — to instructions that sit at awaiting_confirmation until your registered person acts on each one. Nothing before that point is reversible-money-in-flight; all of it is a row you can read, cancel by simply never confirming, and forget.

Errors worth handling

Every code in this section's shape is catalogued with what to do about it in Errors — Transfers and Confirmations. The two worth highlighting here:

StatusMeaning
409TRANSFER_IDEMPOTENCY_CONFLICT — the same idempotencyKey was used for a different request; or the transfer already moved past awaiting_confirmation.
422The destination failed validation, the name did not match, or the transfer breaches your per-transfer or daily cap.

On this page