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, or out of your USD account over RTGS, EFT or SWIFT (see Paying out USD). 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.

By default, 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 (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. The one exception is an account OnLink has enabled confirmation waiving for; see When confirmation is waived.

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 — not to your integration, unless you have asked for codes by webhook.
  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.
  • By default 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. The one exception is an account OnLink has switched to receive codes by webhook: the code then goes to your signed webhook endpoint instead, and your endpoint takes the place of the contact. See Auto-confirm instructions.
  • 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. Unless your account receives confirmation codes by webhook, 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. If your account does receive codes by webhook, that cap is gone: a single backend that holds both your API secret and your webhook endpoint holds both factors, so a compromise of that backend can move money with nobody reading out a code. That is the price of confirming from code; see the Security section of Auto-confirm instructions.

When confirmation is waived

OnLink can enable confirmation waiving for your partner account, on request. When it is on, POST /v1/transfers creates the transfer already confirmed, with no confirmation object in the response (the key is absent, not null), and starts executing it straight away. There is no code to read and no POST /v1/confirmations/:id call to make. You still receive transfer.settled or transfer.failed as usual, and no confirmation.code_issued event is sent.

  • Handle both answers on create. Your integration must accept confirmed and awaiting_confirmation in the create response, and only post a code when there is a confirmation block to post it to. A waived account can still be asked for a code, as below, and waiving can be switched off at any time.
  • In production, the waiver applies only to a key with an IP allowlist. A waived account whose API key has no IP allowlist is not refused: that request gets a code exactly as in the rest of this guide, and the confirmation block carries "waiverNotApplied": "key_without_ip_allowlist" so you can see why. That field is on the first response only: repeating the same request (an idempotent replay) returns the stored transfer without it. To fix 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.
  • Everything else still runs first. Validation, name verification, caps and your balance are checked before the transfer is created, so a waived transfer that fails any of them is refused exactly as before.
  • Registering a USDT withdrawal address still needs its code. Waiving applies to payouts, not to wallet registration.

The allowlist is what stands in for the code. With a waiver, a stolen API key used from outside your allowlisted addresses can still only create transfers that wait for a code; a compromise of a backend inside your allowlisted network can move money up to your caps. Weigh that before you ask for 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.

Paying out USD

If you hold USD in your onlink USD account (for example from collecting in the US), you can pay it out as USD, without converting it first: send amount.currency "USD". The money leaves your USD account, never your KES one, and the beneficiary is paid in USD. Nothing is converted on the way out.

USD payouts are for Collections accounts, the accounts that hold a USD balance. A USDT Rails account sends KES only: its USD transfer or USD batch is refused 422 TRANSFER_CURRENCY_NOT_ENABLED on create, on every rail, with details.currency and details.enabledCurrencies (["KES"]), before anything is written.

RailKESUSD
rtgsYesYes, to a USD account at a Kenyan bank.
eftYesYes, to a USD account at a Kenyan bank.
swiftYesYes, to a USD account. swift.accountCurrency defaults to USD.
mpesaYesNo. M-PESA pays KES only.
pesalinkYesNo. PesaLink pays KES only.
internalYesNo.
  • A USD payout pays a USD account. A USD transfer whose swift.accountCurrency names another currency is refused, as is USD on a rail that does not take it: 422 TRANSFER_RAIL_CURRENCY_UNSUPPORTED, with details.rail and details.currency (and details.destinationCurrency for the SWIFT case). No single transfer pays KES out of your USD account: to pay KES from USD, convert it first with POST /v1/conversions (Collections accounts), then send an ordinary KES transfer.
  • Everything else is the same. The destination rules in the table above, the confirmation code (or waiver), idempotency, the statuses and the transfer.settled / transfer.failed webhooks are unchanged. amount and fee on every response and webhook carry "currency": "USD".
  • The fee is in USD: USD 5.00 on rtgs and eft, USD 28.00 on swift with chargeType OUR. BEN charges you nothing (the beneficiary bears it), and SHA quotes no fee, exactly as for KES.
  • Your balance is your USD account's. A USD transfer is checked against, and paid from, your USD balance; your KES balance is not read. You read that balance as usd on GET /v1/balances, and a short USD account is refused 422 TRANSFER_INSUFFICIENT_BALANCE on create.
  • Caps stay in KES. Your per-transfer and daily caps are KES figures, and a USD transfer counts against them at its KES equivalent at the market mid-rate when you create it, rounded up. It shares the one daily ceiling with your KES transfers, orders and USDT sends; it does not get a ceiling of its own. If no usable rate is available the create is refused 503 TRANSFER_DAILY_CAP_RATE_UNAVAILABLE: retry shortly.
  • Name verification: rtgs, eft and swift have no name resolver, so a USD transfer is always nameVerification: "unavailable".

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":"254700000000","accountName":"Jane Wanjiku"},"amount":{"currency":"KES","value":"1500.00"},"partnerReference":"ACME-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>" }, or "USD" (see Paying out USD). 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": "254700000000",
    "accountName": "Jane Wanjiku"
  },
  "nameVerification": "verified",
  "verifiedAccountName": "JANE WANJIKU",
  "partnerReference": "ACME-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

If the create response was confirmed with no confirmation object (see When confirmation is waived), there is no code: skip steps 3 and 4 and wait for the webhook.

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. It is in a webhook only if your account is set to receive codes by webhook (confirmation.code_issued); otherwise 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. A USD transfer counts at its KES equivalent (see Paying out USD). 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 (or, if your account receives codes by webhook, until your endpoint does). 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, the transfer breaches your per-transfer or daily cap, or the rail does not take the currency (TRANSFER_RAIL_CURRENCY_UNSUPPORTED).

On this page