Buy USDT with KES
Debit your own KES balance directly, receive USDT at a registered address. Quote, create, settle — no payer, no payment rail.
You debit your own KES balance; USDT arrives at one of your registered withdrawal addresses. Three steps: quote, create, settle. There is no payer and no payment rail on this leg — the KES leaves your own VA 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: Nine messages between you, OnLink, the bank and the Tron network. There is no payer. You take a quote and receive a rate. You create a buy order. OnLink debits your own VA 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 sends USDT to your registered address and sends you the settled webhook.
participant You
participant OnLink
participant Bank
participant Tron
You->>OnLink: POST /v1/quotes (side=buy)
OnLink-->>You: quoteId, rate, expiresAt
You->>OnLink: POST /v1/orders/buy (quoteId, partnerReference)
OnLink->>Bank: debit your VA balance
OnLink-->>You: 202 orderId, kesDebit.status=reserved
Bank-->>OnLink: debit confirmed
OnLink-->>You: webhook order.funds_confirmed
OnLink->>Tron: send USDT to your address
OnLink-->>You: webhook order.settled1. 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.
2. Create the order
#!/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. |
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. |
There is no walletId and no paymentRail to supply. Which registered address
receives the USDT is decided by your account configuration, not by this call; and
there is no payment rail because there is no external payment.
Response — 202 Accepted:
{
"orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24",
"status": "funds_reserved",
"kesAmount": "130500.00",
"usdtAmount": "1000.000000",
"rate": "130.5000",
"kesTotalAmount": "132979.80",
"fee": {
"kesAmount": "2479.80",
"currency": "KES",
"percentBps": 190,
"flatKesAmount": "0.30",
"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 through a webhook or by reading the order again.
kesTotalAmount is what leaves your balance
kesAmount is the trade principal; kesTotalAmount is the principal plus
the disclosed fee — 130500.00 + 2479.80 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. -
confirmed— the bank confirmed the debit. Your order has moved on (awaiting_approvalor later). -
failed— your order isrejected. This covers two different moments, and only one of them means nothing was taken:- Refused before or without reaching the bank (
KES_DEBIT_FAILEDor a lateTREASURY_ACCOUNT_UNAVAILABLEfromPOST /v1/orders/buyitself) — nothing was taken; a failed debit here reverses nothing because it moved nothing. - Rejected later, after the bank had already accepted the debit (its own asynchronous result comes back terminal-failed) — your KES was taken, and a reversal is claimed against it. A claimed reversal is not yet a refund you have received; it is the record that one is owed. Read the order rather than assuming from the word "rejected" alone.
See Errors for every code that can produce the first case.
- Refused before or without reaching the bank (
4. Settle
The order moves funds_reserved → funds confirmed → settled. You will receive:
order.funds_confirmed— the bank confirmed your KES debit.kesDebitmoves fromreservedtoconfirmed.order.settled— the USDT is on its way to your registered address.order.rejected— terminal. Most often your debit failed or was refused before the bank was ever called, and nothing was taken. But this event also fires when the bank's OWN asynchronous result comes back terminal-failed after it had already accepted the debit — there, your KES was taken and a reversal is claimed against it, not yet a refund you have received. ReadkesDebit.statuson the order rather than assuming from the event name, then create a new order against a fresh quote.
GET /v1/orders/{id} returns the same shape as create, so you can poll it as a
fallback to webhooks:
{
"status": "settled",
"kesDebit": { "status": "confirmed" }
}Errors worth handling
| Status | Meaning |
|---|---|
409 | The quote expired or was already used, or the partnerReference already names a sell order. |
422 | The order exceeds your per-order or rolling-24-hour cap, or your KES balance cannot cover it. |
503 | Something upstream — a platform-wide control, your account readiness, or the debit itself — is not ready right now. |
See Errors for the full buy-leg catalogue and Caps and limits for the ceilings.