Sell USDT, receive KES
Quote, create, send USDT, attach the hash, receive KES. Five steps.
You send USDT; KES arrives in a payout account you registered. Five steps — the extra one is attaching the transaction hash, and it is not optional.
Settlement is asynchronous
A create returns 202, not 201 — the order exists, but nothing has happened
to the money yet. Register a webhook endpoint and treat the
terminal event as the completion signal; GET /v1/orders/{id} is the
fallback.
sequenceDiagram
autonumber
participant You
participant OnLink
participant Tron
participant Bank as Your payout account
You->>OnLink: POST /v1/quotes (side=sell)
OnLink-->>You: quoteId, rate, expiresAt
You->>OnLink: POST /v1/orders/sell (quoteId, partnerReference)
OnLink-->>You: 202 orderId, depositAddress, sendBefore
You->>Tron: send USDT
You->>OnLink: PATCH /v1/orders/{id} (txHash)
OnLink->>OnLink: match deposit
OnLink-->>You: webhook order.funds_confirmed
OnLink->>Bank: pay out KES
OnLink-->>You: webhook order.settled1. Quote
POST /v1/quotes with side: "sell". Single-use, and it expires — the amounts come
from it, so you do not restate them on create.
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/sell"
BODY='{"quoteId":"3f1c9b6e-0d2a-4a71-9c8e-51b2a7f4d380","partnerReference":"your-own-handle-0002","payoutAccountId":"6d4b8f02-9c17-4e35-a8d0-3f5c7b1e9a42"}'
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=sell. Single-use. |
partnerReference | Your own handle, unique per partner. Retry-safe — resending returns the original order. |
payoutAccountId | Which registered KES account receives the funds. See GET /v1/payout-accounts. |
Response — 202 Accepted:
{
"orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24",
"status": "awaiting_usdt",
"kesAmount": "129500.00",
"usdtAmount": "1000.000000",
"rate": "129.5000",
"expiresAt": "2026-09-01T12:34:56.000Z",
"depositAddress": "TRXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"chain": "tron",
"sendBefore": "2026-09-01T12:34:56.000Z"
}3. Send the USDT
Send usdtAmount of USDT on Tron (TRC-20) to depositAddress, before
sendBefore.
Your deposit address is the same for every order
It is long-lived and shared across all of your orders, so it cannot tell two of them apart. Sending without completing step 4 leaves us with a deposit we cannot attribute.
4. Attach the transaction hash
PATCH /v1/orders/{orderId}{ "txHash": "a1b2c3d4e5f6..." }This is what attributes your deposit to this order. Case and a leading 0x are
ignored, since those are the same on-chain transaction. One hash attributes exactly
one order — attaching a hash already used by another order is refused.
5. Settle
You will receive:
order.funds_confirmed— your USDT was attributed to this order. On this leg attribution depends on the hash you supplied, so a missing confirmation is actionable by you: check the hash.order.settled— the KES has been sent to your payout account.order.rejected/order.expired— terminal, and the remedies differ. An expiry means quote and send again; a rejection does not.
Errors worth handling
| Status | Meaning |
|---|---|
409 | The quote expired or was already used; the partnerReference already names a buy order; or that hash is already attached to another order. |
422 | The order exceeds your per-order or rolling-24-hour cap. |
See Errors for the full catalogue and References and attribution for how hashes are matched.