OnLink
Guides

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

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.

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"
FieldNotes
quoteIdFrom POST /v1/quotes with side=buy. Single-use.
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.

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_approval or later).

  • failed — your order is rejected. This covers two different moments, and only one of them means nothing was taken:

    • Refused before or without reaching the bank (KES_DEBIT_FAILED or a late TREASURY_ACCOUNT_UNAVAILABLE from POST /v1/orders/buy itself) — 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.

4. Settle

The order moves funds_reserved → funds confirmed → settled. You will receive:

  • order.funds_confirmed — the bank confirmed your KES debit. kesDebit moves from reserved to confirmed.
  • 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. Read kesDebit.status on 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

StatusMeaning
409The quote expired or was already used, or the partnerReference already names a sell order.
422The order exceeds your per-order or rolling-24-hour cap, or your KES balance cannot cover it.
503Something 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.

On this page