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.
Your USDT is taken from your own balance the instant you create the order — either debited from your USDT balance directly, or collected from your own wallet, depending on your account's configured wallet kind. KES arrives in your own OnLink KES account, which is your payout account. Three steps: quote, create, settle.
Settlement is asynchronous
A create returns 202, not 201 — the order exists, and your USDT has
already been taken (or its collection already started), but the KES payout has
not happened yet. Register a webhook endpoint and treat the
terminal event as the completion signal; GET /v1/orders/{id} is the
fallback.
sequenceDiagram
autonumber
accTitle: Selling USDT and receiving shillings
accDescr: Six messages between you, OnLink and your payout account. You take a quote and receive a rate. You create a sell order, naming your payout account. OnLink debits your own USDT balance and returns a 202 whose status already shows usdt_received. OnLink pays shillings to your payout account and sends you the settled webhook.
participant You
participant OnLink
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, payoutAccountId)
OnLink->>OnLink: debit your USDT balance
OnLink-->>You: 202 orderId, status=usdt_received
OnLink->>Bank: pay out KES
OnLink-->>You: webhook order.settledThis is the default managed-balance wallet kind. If your account is configured for your-own-wallet delivery instead, OnLink collects the USDT out of your own wallet at the same point in the flow — see USDT collection below for how that variant differs.
1. 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. Needs the quotes:write
scope, on a credential whose product is usdt_rails — a collections
credential gets 403 with error: "product_not_supported".
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/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 | One of your OnLink KES accounts, from GET /v1/payout-accounts. The KES is paid there. |
Response — 202 Accepted (managed-balance kind, the default — your USDT is
already debited and the order's status already reads usdt_received):
{
"orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24",
"status": "usdt_received",
"kesAmount": "1281000.00",
"usdtAmount": "10000.000000",
"rate": "128.1000",
"kesTotalAmount": "1268180.00",
"fee": {
"kesAmount": "12820.00",
"currency": "KES",
"percentBps": 100,
"flatKesAmount": "10.00",
"capKesAmount": null
},
"expiresAt": "2026-09-01T12:34:56.000Z",
"depositAddress": "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb",
"chain": "tron",
"sendBefore": "2026-09-01T12:34:56.000Z"
}kesAmount is the trade principal. What lands in your account is
kesTotalAmount — the principal less the disclosed fee, 1281000.00 − 12820.00 above — so that is the figure to reconcile your bank statement
against. The fee object shows the exact terms applied to this order;
Fees has the arithmetic.
Note that usdtAmount is unchanged by the fee: the fee is charged in KES, on
the KES side, on both legs.
3. USDT collection
Neither wallet kind needs you to send anything or attach a transaction hash — your USDT is taken the instant you create the order:
- Managed balance (the default). Your USDT balance is debited directly at
creation. The order's
statusis alreadyusdt_receivedin the202response above, andorder.funds_confirmedis enqueued at the same moment. - Your own wallet. OnLink collects the amount out of your own wallet at
creation — the same wallet whose address
GET /v1/fundingpublishes for topping it up. The order staysawaiting_usdtuntil that collection is confirmed, then moves tousdt_received(order.funds_confirmedfires then).
That deposit address tops up your USDT balance generally — it is not tied
to any one order, and sending to it does not itself fund a specific sell
order. GET /v1/usdt/balance shows the USDT you can use now (see Your USDT
balance); it is a reading, not a
reservation. GET /v1/usdt/holding-wallet returns that address and says what
USDT sent to it does for your account (see Where your USDT is
held).
PATCH /v1/orders/{id} is not part of this flow
It still exists on the API and accepts a Tron transaction hash for a sell
order still awaiting_usdt, but neither flow above needs it — you never send
USDT yourself, so there is no hash to attach. If an order is not moving on its
own, contact us with the orderId rather than reaching for this endpoint as a
workaround.
Testing in sandbox
No sell order needs a simulated USDT receipt today. When the USDT comes from
your balance, it is taken when you create the order, so the sandbox's USDT
receipt simulation answers 409 ORDER_NOT_AWAITING_USDT. When it comes from
your own wallet, we collect it, the sandbox completes the collection within
about a minute of the transfer completing and the order moves on from
awaiting_usdt, so that simulation answers 409 ORDER_USDT_COLLECTED_AUTOMATICALLY. See Testing in
sandbox for the route.
4. Settle
You will receive:
order.funds_confirmed— your USDT was attributed to this order.order.settled— the KES has been paid into your payout account. Pay it onward withPOST /v1/transfers.order.rejected— terminal. For the managed-balance kind your USDT was already debited before this can fire, so your balance has moved; for the your-own-wallet kind, whether anything left your wallet depends on how far the collection got. Either way there is no self-service reversal — contact us with theorderId.order.expired— terminal. Only reachable for the your-own-wallet kind, and only if no collection was ever submitted within the deposit window; once one has been, the order is held for us instead. The managed-balance kind is debited or refused at creation, so it has no deposit window and does not expire this way once debited. Quote and create again if you still want the trade.
Errors worth handling
| Status | Meaning |
|---|---|
409 | The quote expired or was already used, or the partnerReference already names a buy order. |
422 | The order exceeds your per-order or rolling-24-hour cap, or (INSUFFICIENT_USDT_BALANCE) your USDT balance cannot cover it (managed-balance kind). |
503 | Your-own-wallet collection was refused (USDT_COLLECTION_FAILED: nothing was collected and the order is rejected), or we could not confirm whether it went through (USDT_COLLECTION_OUTCOME_UNKNOWN: the order is held in review, so do not create another order or resend USDT; read it with GET /v1/orders/{orderId} and contact us with the requestId), or that delivery kind is disabled for you (CUSTOMER_WALLETS_DISABLED: no order was created and your quote was not used, so you can retry with the same partnerReference once it is enabled). |
See Errors for the full catalogue and Caps and limits for the ceilings.
Receive money in Kenya
Two Kenyan rails credit your own KES balance — the same balance the buy leg debits directly. Neither rail attaches to an order, and the credit.received webhook is your only completion signal.
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.