OnLink
Guides

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

This 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"
FieldNotes
quoteIdFrom POST /v1/quotes with side=sell. Single-use.
partnerReferenceYour own handle, unique per partner. Retry-safe — resending returns the original order.
payoutAccountIdOne 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 status is already usdt_received in the 202 response above, and order.funds_confirmed is 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/funding publishes for topping it up. The order stays awaiting_usdt until that collection is confirmed, then moves to usdt_received (order.funds_confirmed fires 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 with POST /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 the orderId.
  • 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

StatusMeaning
409The quote expired or was already used, or the partnerReference already names a buy order.
422The order exceeds your per-order or rolling-24-hour cap, or (INSUFFICIENT_USDT_BALANCE) your USDT balance cannot cover it (managed-balance kind).
503Your-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.

On this page