OnLink
Guides

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.settled

order.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"
FieldNotes
quoteIdFrom 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.
partnerReferenceYour own reconciliation handle, unique per partner. Retry-safe — see below.
expectedUsdtAmountOptional. 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 is funds_reserved.
  • confirmed — the bank confirmed the debit, and order.funds_confirmed is on its way to you. The order is funds_reserved for a moment longer, then awaiting_approval or later — any status except rejected.
  • failed — the order is rejected and 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 the orderId. See Errors for every code that can produce a clean refusal at create time.
  • reversal_pending — the order is rejected after 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 reads reversed. 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 with GET /v1/orders/{id}. We do not publish a time for the return; if the order stays reversal_pending and you need a date, contact us with the orderId.
  • reversed — the KES from a reversal_pending order 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 sending status — it moves from awaiting_approval straight to settled.
  • Your own wallet. USDT is sent on-chain to the Tron wallet we provision for you. The order passes through sending before settled.

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. Read kesDebit.status on the order to learn what happened to your KES — failed (no record it was taken) or reversal_pending (taken, owed back), which becomes reversed once 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

StatusMeaning
404Unknown quoteId.
409The 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.
422The 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.
503A 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.

On this page