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.failedThe same eight steps, written out — everything above is here, so nothing in this guide depends on seeing the picture:
- You call
POST /v1/transferswith the rail, the destination and the amount. - We verify the beneficiary name against the rail, and check the transfer against your caps and your KES balance.
- We deliver the one-time confirmation code to your registered confirmation contact — not to your integration, unless you have asked for codes by webhook.
- We answer you
202, carrying the transfer'sidand aconfirmationblock with its ownidandexpiresAt. - Your registered person passes you the code over your own channel — a call, a chat, an operator desk. We are not in that step.
- You call
POST /v1/confirmations/:idwith that code. - We answer
200withstatus=confirmed, and only then dispatch to the bank and wait for the bank to answer. - We post you one of two webhooks:
transfer.settledif the bank completed it, ortransfer.failedif the bank answered and refused it.
The consent model, plainly
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/:idwith 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 atawaiting_confirmationand 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
confirmedandawaiting_confirmationin the create response, and only post a code when there is aconfirmationblock 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
confirmationblock 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 with503CONFIRMATION_CONTACT_MISSINGorCONFIRMATION_DELIVERY_UNAVAILABLE, and that503carriesdetails.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.
| Rail | accountNumber | Also required | Not allowed |
|---|---|---|---|
mpesa | A Kenyan mobile number, any recognised format — normalised server-side. | — | bankCode, bankName, branchCode, swift, paymentPurposeId, senderAddress |
pesalink | 6–20 digits. | bankCode | bankName, branchCode, swift, paymentPurposeId, senderAddress |
rtgs | 6–34 alphanumeric characters. | bankCode, bankName, paymentPurposeId, senderAddress. branchCode optional. | swift |
eft | 6–34 alphanumeric characters. | Same as rtgs. | swift |
swift | 6–34 alphanumeric characters. | bankName, paymentPurposeId, senderAddress, and a swift object: bic, bankCity, bankCountry (spelled out in full — never an ISO code). | bankCode, branchCode |
internal | 6–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.
| Rail | KES | USD |
|---|---|---|
rtgs | Yes | Yes, to a USD account at a Kenyan bank. |
eft | Yes | Yes, to a USD account at a Kenyan bank. |
swift | Yes | Yes, to a USD account. swift.accountCurrency defaults to USD. |
mpesa | Yes | No. M-PESA pays KES only. |
pesalink | Yes | No. PesaLink pays KES only. |
internal | Yes | No. |
- A USD payout pays a USD account. A USD transfer whose
swift.accountCurrencynames another currency is refused, as is USD on a rail that does not take it:422TRANSFER_RAIL_CURRENCY_UNSUPPORTED, withdetails.railanddetails.currency(anddetails.destinationCurrencyfor the SWIFT case). No single transfer pays KES out of your USD account: to pay KES from USD, convert it first withPOST /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.failedwebhooks are unchanged.amountandfeeon every response and webhook carry"currency": "USD". - The fee is in USD: USD 5.00 on
rtgsandeft, USD 28.00 onswiftwithchargeTypeOUR.BENcharges you nothing (the beneficiary bears it), andSHAquotes 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
usdonGET /v1/balances, and a short USD account is refused422TRANSFER_INSUFFICIENT_BALANCEon 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
503TRANSFER_DAILY_CAP_RATE_UNAVAILABLE: retry shortly. - Name verification:
rtgs,eftandswifthave no name resolver, so a USD transfer is alwaysnameVerification: "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"| Field | Notes |
|---|---|
rail | One of mpesa, pesalink, rtgs, eft, swift, internal. |
destination | Per the table above. |
amount | { currency: "KES", value: "<decimal string>" }, or "USD" (see Paying out USD). Greater than zero, at most 2 decimal places. |
partnerReference | Your own handle, echoed back on every read. Optional, but there is no list-transfers endpoint, so record one. |
idempotencyKey | Required. 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.verifiedAccountNamecarries 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,eftandswifthave no name resolver at all, so they are alwaysunavailable; onmpesa,pesalinkandinternal,unavailableinstead 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, andfailureCodeon 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 toexpired; 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}
status | Meaning | Webhook |
|---|---|---|
awaiting_confirmation | Waiting on the code. Nothing sent to the bank yet. | none — you already have the 202 |
confirmed | Code verified; handed off for execution. | none — you already have the 200 confirm response |
executing | Being dispatched. May also mean an earlier attempt could not be resolved — see the callout above. | none |
processing | The bank accepted it and is completing it. | none |
settled | Done. Value moved. | transfer.settled |
failed | The bank answered and refused it. | transfer.failed |
expired | The confirmation code's window passed unconfirmed. | confirmation.expired |
cancelled | Withdrawn 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:
| Status | Meaning |
|---|---|
409 | TRANSFER_IDEMPOTENCY_CONFLICT — the same idempotencyKey was used for a different request; or the transfer already moved past awaiting_confirmation. |
422 | The 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). |
Buy USDT with KES
Debit your own KES balance directly, receive USDT by your configured wallet kind. Quote, create, settle — no payer, no payment rail.
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.