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.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 — never to your integration.
- 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.
- 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/: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. 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.
| 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.
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"| Field | Notes |
|---|---|
rail | One of mpesa, pesalink, rtgs, eft, swift, internal. |
destination | Per the table above. |
amount | { currency: "KES", value: "<decimal string>" }. 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": "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.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
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, 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. 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:
| 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, or the transfer breaches your per-transfer or daily cap. |
Buy USDT with KES
Debit your own KES balance directly, receive USDT at a registered address. 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.