OnLink
Guides

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

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.

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"
FieldNotes
quoteIdFrom POST /v1/quotes with side=sell. Single-use.
partnerReferenceYour own handle, unique per partner. Retry-safe — resending returns the original order.
payoutAccountIdWhich 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

StatusMeaning
409The quote expired or was already used; the partnerReference already names a buy order; or that hash is already attached to another order.
422The 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.

On this page