OnLink
Guides

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

1. 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"
FieldNotes
quoteIdFrom POST /v1/quotes with side=buy. Single-use.
partnerReferenceYour own reconciliation handle, unique per partner. Retry-safe — see below.
walletIdWhich registered withdrawal address receives the USDT. Must already be active. See GET /v1/wallets.
paymentRailOne 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

StatusMeaning
409The quote expired or was already used, or the partnerReference already names a sell order.
422The order exceeds your per-order or rolling-24-hour cap.

See Errors for the full catalogue and Caps and limits for the ceilings.

On this page