Buy USDT with KES
Send KES to your collection account, receive USDT at a registered address. Quote, create, pay, settle.
You send KES; USDT arrives at one of your registered withdrawal addresses. Four steps: quote, create, pay, settle.
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 Payer as Your payer
participant You
participant OnLink
participant Tron
You->>OnLink: POST /v1/quotes (side=buy)
OnLink-->>You: quoteId, rate, expiresAt
You->>OnLink: POST /v1/orders/buy (quoteId, partnerReference)
OnLink-->>You: 202 orderId, paymentInstructions, expiresAt
You->>Payer: show paybill and account number
Payer->>OnLink: pay KES
OnLink->>OnLink: match payment
OnLink-->>You: webhook order.funds_confirmed
OnLink->>Tron: send USDT to your address
OnLink-->>You: webhook order.settled1. 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","walletId":"9a7d2e14-6b83-4c05-8f19-2d6e4a1b7c93","paymentRail":"mpesa"}'
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=buy. Single-use. |
partnerReference | Your own reconciliation handle, unique per partner. Retry-safe — see below. |
walletId | Which registered withdrawal address receives the USDT. Must already be active. See GET /v1/wallets. |
paymentRail | One of mpesa, pesalink, rtgs, eft. Not cosmetic — the rate guarantee is per-rail, and attribution differs by rail. See References. |
Response — 202 Accepted:
{
"orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24",
"status": "awaiting_payment",
"kesAmount": "130500.00",
"usdtAmount": "1000.000000",
"rate": "130.5000",
"expiresAt": "2026-09-01T12:34:56.000Z",
"paymentReference": "OLA1B2C3D4E5",
"paymentInstructions": {
"rail": "mpesa",
"paybill": "000000",
"payTo": "00000000123456",
"beneficiaryName": "OnLink Technology Inc",
"reference": "OL-7F3K9QB2",
"kesAmount": "130500.00",
"currency": "KES",
"payBefore": "2026-09-01T12:34:56.000Z",
"instructions": "M-PESA > Lipa na M-PESA > Pay Bill > Business number 000000 > Account number 00000000123456 > Amount 130500.00. The Account number is what identifies you to us; M-PESA carries no narration, so keep the reference OL-7F3K9QB2 in your own records and quote it if you need to ask about this order."
}
}paymentReference is ours, not yours — that is what makes attribution exact
rather than guessed. partnerReference stays your handle for your own books.
Read the payment instructions from the response, never from this page
The paybill, payTo and every other value in the example above are
placeholders. Your collection details are issued to you and can change. Read
them from paymentInstructions on the create response, or from GET /v1/funding, which is the source of truth
for where your payers send money. Money sent to a hardcoded account number is
not attributable to an order.
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 — resend the same partnerReference rather
than generating a new one. A reference already used by a sell order is
refused with 409.
3. Pay
Use the paymentInstructions from the create response.
On mpesa, pay the paybill with payTo as the Account Number. M-PESA carries
no narration field, so the reference cannot be transmitted at all — keep
paymentReference in your own records. The Account Number is your dedicated
collection account, which is what attributes the payment.
On bank_transfer (PesaLink, RTGS or EFT), send to payTo and put
paymentReference in the narration. It is delivered on these rails, and it gives
exact attribution.
Pay the kesAmount exactly, and before expiresAt.
4. Settle
The order moves to awaiting_payment → funds confirmed → settled. You will receive:
order.funds_confirmed— your money was attributed to this order. Until this fires you cannot tell "not arrived" from "arrived, unattributed".order.settled— the USDT is on its way to your registered address.order.rejected/order.expired— terminal, and the remedies differ. An expiry means quote and send again; a rejection does not.
GET /v1/orders/{id} returns the same fields plus a payment object once funds are
attributed:
{
"payment": {
"providerReference": "SBX0EXAMPLE",
"bankTransactionId": "SBXTRANS00000000EXAMPLE"
}
}providerReference is the M-PESA or bank code your own payer recognises — pass
it on to them if you show them a receipt. Both are null until a payment is
attributed.
Errors worth handling
| Status | Meaning |
|---|---|
409 | The quote expired or was already used, or the partnerReference already names a sell order. |
422 | The order exceeds your per-order or rolling-24-hour cap. |
See Errors for the full catalogue and Caps and limits for the ceilings.