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.
You debit your own KES balance; USDT arrives by whichever delivery kind your account is configured with — credited straight to your USDT balance by default, or sent on-chain to your own wallet. Three steps: quote, create, settle. There is no payer and no payment rail on this leg — the KES leaves your own KES balance the instant you create the order.
Settlement is asynchronous
A create returns 202, not 201 — the order exists, and your KES debit has
already been attempted, but the bank has not yet confirmed it and the USDT has
not moved. Register a webhook endpoint and treat the
terminal event as the completion signal; GET /v1/orders/{id} is the
fallback.
sequenceDiagram
autonumber
accTitle: Buying USDT with shillings
accDescr: Eight messages between you, OnLink and the bank. There is no payer. You take a quote and receive a rate. You create a buy order. OnLink debits your own KES balance at the bank and returns a 202 with an order id and a reserved KES debit status. The bank confirms the debit, and OnLink sends you the funds-confirmed webhook. OnLink delivers the USDT by your account's configured wallet kind and sends you the settled webhook.
participant You
participant OnLink
participant Bank
You->>OnLink: POST /v1/quotes (side=buy)
OnLink-->>You: quoteId, rate, expiresAt
You->>OnLink: POST /v1/orders/buy (quoteId, partnerReference)
OnLink->>Bank: debit your KES balance
OnLink-->>You: 202 orderId, kesDebit.status=reserved
Bank-->>OnLink: debit confirmed
OnLink-->>You: webhook order.funds_confirmed
OnLink->>OnLink: deliver USDT by your wallet kind
OnLink-->>You: webhook order.settledorder.funds_confirmed means the bank confirmed your debit
We send it once, when the bank confirms the KES debit for this order — the
same event a sell order sends when its USDT lands, so one handler covers both
legs. Its data.status is the order's status when the event was recorded —
normally funds_reserved, because the order has not moved on yet. It is
progress, not completion: build on order.settled and order.rejected, with
GET /v1/orders/{id} as the fallback. Like every webhook it is delivered at
least once and a retried delivery can arrive after a later event, so dedupe on
the delivery id and let the terminal event win.
1. Quote
POST /v1/quotes with side: "buy". A quote is single-use and expires — the
amounts come from it, so you do not restate them when you create the order.
Needs the quotes:write scope, on a credential whose product is usdt_rails —
a collections credential gets 403 with error: "product_not_supported".
If your USDT is delivered to your own wallet, name the amount with usdtAmount
(at least "5.00", at most 2 decimal places) or with kesAmount. A quote named
by kesAmount rounds its usdtAmount down to 2 decimal places, by less than
0.01 USDT, so kesAmount × rate can exceed the usdtAmount you receive by that
much; kesAmount itself stays exactly as you named it. The quote is refused with
USDT_SEND_BELOW_MINIMUM if the rounded amount is under 5 USDT, and a
usdtAmount with more than 2 decimal places is refused with
USDT_SEND_AMOUNT_PRECISION. If your USDT is credited to your managed balance
(the default), the quote keeps all 6 decimal places.
2. Create the order
Needs the orders:write scope on the same usdt_rails credential.
#!/usr/bin/env bash
# Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./order-create.sh
set -euo pipefail
HOST="https://sandbox.onlink.africa"
METHOD="POST"
PATH_AND_QUERY="/v1/orders/buy"
BODY='{"quoteId":"3f1c9b6e-0d2a-4a71-9c8e-51b2a7f4d380","partnerReference":"your-own-handle-0001"}'
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 |
|---|---|
quoteId | From POST /v1/quotes with side=buy. Single-use. If your USDT is delivered to your own wallet, its usdtAmount must be at least 5 USDT with at most 2 decimal places. |
partnerReference | Your own reconciliation handle, unique per partner. Retry-safe — see below. |
expectedUsdtAmount | Optional. If supplied it must equal the quote's USDT amount exactly, or the order is refused (409) before your quote is consumed. |
If your USDT is delivered on-chain to your own wallet, the quote's
usdtAmount is checked first, before the quote is consumed and before any KES
is debited: under 5 USDT is refused with 422 USDT_SEND_BELOW_MINIMUM, and
more than 2 decimal places with 422 USDT_SEND_AMOUNT_PRECISION — the same
codes a USDT send returns. The same check runs when you take the quote, so you
learn there rather than here. If your USDT is credited to your managed balance
(the default), neither limit applies. See
Caps and limits.
There is no walletId field, and nothing describing an external payment
rail. How the USDT reaches
you — credited to your balance, or sent on-chain — is decided by your
account's configured wallet kind, not by this call; see USDT
delivery below. There is no payment rail because there is no
external payment.
Response — 202 Accepted:
{
"orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24",
"status": "funds_reserved",
"kesAmount": "1281000.00",
"usdtAmount": "10000.000000",
"rate": "128.1000",
"kesTotalAmount": "1293820.00",
"fee": {
"kesAmount": "12820.00",
"currency": "KES",
"percentBps": 100,
"flatKesAmount": "10.00",
"capKesAmount": null
},
"expiresAt": "2026-09-02T09:30:00.000Z",
"kesDebit": { "status": "reserved" }
}kesDebit.status is reserved the instant this response comes back — your KES
debit has already been attempted against your own balance. It is not yet
confirmed: that happens once the bank's own asynchronous result arrives, and
you learn it from the order.funds_confirmed webhook or by reading the order
again.
expiresAt on this leg is informational only. Nothing expires a
funds_reserved order — your debit is attempted the instant you create it, so
there is nothing left unfunded to time out; the order runs to a terminal state
on its own timeline, not against this deadline.
kesTotalAmount is what leaves your balance
kesAmount is the trade principal; kesTotalAmount is the principal plus
the disclosed fee — 1281000.00 + 12820.00 above — and that is the figure
actually debited. Reconcile your statement against kesTotalAmount, and size
your balance against it too: a balance that covers only the principal is
refused with INSUFFICIENT_KES_BALANCE rather than part-filled.
Fees has the arithmetic; the fee object shows the
exact terms applied to this order. With no fee priced, the two are equal.
Creating twice with the same partnerReference is safe
It returns the original order, not a second one. So a timeout or a retried
request cannot produce two orders, and it cannot debit you twice — resend the
same partnerReference rather than generating a new one. A reference already
used by a sell order is refused with 409.
3. Your KES debit
There is no payment step on this leg. kesDebit.status on the order IS the thing
to watch:
reserved— the debit was attempted and is awaiting the bank's own confirmation. This is the status immediately after create, and it can persist for a short while — that is normal, not stuck. The order isfunds_reserved.confirmed— the bank confirmed the debit, andorder.funds_confirmedis on its way to you. The order isfunds_reservedfor a moment longer, thenawaiting_approvalor later — any status exceptrejected.failed— the order isrejectedand we hold no record that your KES was taken: the bank reported the debit as failed, or the order was refused before the debit was made. If the amount did leave your KES balance for this order, contact us with theorderId. See Errors for every code that can produce a clean refusal at create time.reversal_pending— the order isrejectedafter the bank confirmed the debit. Your KES was taken and is owed back: we return the full amount debited for the order —kesTotalAmount, the principal plus the fee — to your KES balance, and the order then readsreversed. It has not been returned yet. There is nothing to retry and no self-service call to make. Keep the order open on your side and read it again withGET /v1/orders/{id}. We do not publish a time for the return; if the order staysreversal_pendingand you need a date, contact us with theorderId.reversed— the KES from areversal_pendingorder has been returned to your KES balance, in full:kesTotalAmount, principal plus fee. This is the final value for that order.
Switching on kesDebit.status? Handle every value
reversal_pending and reversed were added on 2026-09-29, and values may be
added again. A switch that throws on an unrecognised value breaks the day
one appears; treat an unknown value as "not final yet" and read the order
again later.
4. USDT delivery
Once your debit is confirmed, we deliver the USDT. How depends on which wallet kind your account is configured with — we tell you which one applies to your account when we set you up; there is no field on this API that reports it.
- Managed balance (the default). USDT is credited directly to your own
USDT balance on our books. There is no on-chain transfer for this delivery,
and the order never passes through a
sendingstatus — it moves fromawaiting_approvalstraight tosettled. - Your own wallet. USDT is sent on-chain to the Tron wallet we provision
for you. The order passes through
sendingbeforesettled.
Neither kind delivers to a withdrawal address you register with POST /v1/wallets — those addresses are destinations for outbound USDT
sends, a separate rail from buy
orders.
5. Settle
You will receive:
order.funds_confirmed— not terminal. The bank confirmed your KES debit.order.settled— terminal. The USDT has been delivered — credited to your balance, or sent on-chain, depending on your wallet kind. This is the completion signal to build on.order.rejected— terminal. ReadkesDebit.statuson the order to learn what happened to your KES —failed(no record it was taken) orreversal_pending(taken, owed back), which becomesreversedonce the KES is back in your balance. There is no second order webhook when that happens; read the order again. See Your KES debit above.
GET /v1/orders/{id} returns the same shape as create, so you can poll it as a
fallback to webhooks. Needs the orders:read scope:
{
"status": "settled",
"kesDebit": { "status": "confirmed" }
}Errors worth handling
| Status | Meaning |
|---|---|
404 | Unknown quoteId. |
409 | The quote expired or was already used (QUOTE_EXPIRED_OR_CONSUMED), is the wrong side (QUOTE_SIDE_MISMATCH), your account is suspended (PARTNER_SUSPENDED), expectedUsdtAmount does not match the quote (EXPECTED_AMOUNT_MISMATCH), or the partnerReference already names a sell order. |
422 | The order exceeds your per-order or rolling-24-hour cap — which counts principal plus fee, not principal alone — or your KES balance cannot cover the total. If your USDT is delivered to your own wallet, also USDT_SEND_BELOW_MINIMUM (under 5 USDT) and USDT_SEND_AMOUNT_PRECISION (more than 2 decimal places), refused before any KES moves. |
503 | A platform-wide control, your account readiness, or the debit itself is not ready. KES_DEBIT_AMBIGUOUS means we could not confirm the debit's outcome — do not create a new order; re-send the same request with the same partnerReference (it returns the existing order, never a second debit) or wait for the webhook. |
A synchronous KES_DEBIT_FAILED or late TREASURY_ACCOUNT_UNAVAILABLE carries no orderId
The order was created and immediately rejected, but the 503 response body
only carries the failure — not the id. Retrying with the same
partnerReference returns that rejected order (202, status: "rejected")
rather than attempting the debit again; you need a fresh quote and a new
partnerReference to try again. A TREASURY_ACCOUNT_UNAVAILABLE raised
earlier, before your quote is consumed, creates no order and leaves the quote
usable.
See Errors for the full buy-leg catalogue and Caps and limits for the ceilings.
Sell USDT, receive KES
Your USDT is taken from your own balance the instant you create the order. Quote, create, settle — no send step, no hash to attach.
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.