Your first call
A signed GET /v1/health, then a real quote, in curl, Node and Python.
GET /v1/health is the smallest signed request you can make. It takes no body
and no parameters, so it isolates your signing code from everything else: if it
returns 200, your signature, key and clock are all correct.
It is still authenticated. There is no unauthenticated route on this API.
Check the unsigned response first
Before you sign anything, see what an unauthenticated request looks like, so you recognise it later:
curl -i https://sandbox.onlink.africa/v1/healthHTTP/2 401
content-type: application/json; charset=utf-8
x-request-id: 428
{"statusCode":401,"message":"An error occurred","error":"unauthorized"}That is the response for every authentication failure — bad signature, expired timestamp, replayed nonce, unknown key.
Keep the X-Request-Id header. It is the only handle that resolves to your
request in our logs. The body carries the same value as requestId; the
recording above shows only the three fields that never change, and the full
envelope, field by field, is on Errors.
Sign it
Three implementations follow. If you would rather start from a file than from a snippet, the reference signer is the same scheme as a dependency-free Node module with its own test vectors, and the Postman collection signs every request for you.
#!/usr/bin/env bash
# Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./health.sh
set -euo pipefail
HOST="https://sandbox.onlink.africa"
METHOD="GET"
PATH_AND_QUERY="/v1/health"
BODY=""
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}')"
curl -i "${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}"A successful call returns:
{ "status": "ok" }status is always the exact string ok. If the service is not serving you get
an HTTP error, not a degraded value in that field.
Then price something
POST /v1/quotes is the first call that does real work. Supply one leg and the
other is derived:
{
"side": "sell",
"usdtAmount": "250.000000"
}The response locks a rate:
{
"quoteId": "f1c0a5d2-3b4e-4a71-9c8d-0e1f2a3b4c5d",
"side": "sell",
"kesAmount": "32025.00",
"usdtAmount": "250.000000",
"rate": "128.10",
"kesTotalAmount": "31416.22",
"fee": {
"kesAmount": "608.78",
"currency": "KES",
"percentBps": 190,
"flatKesAmount": "0.30",
"capKesAmount": null
},
"expiresAt": "2026-09-04T09:31:30.000Z",
"settlementEstimateSeconds": 21600
}Four things to notice, because each is a decision you have to make now rather than later:
- Every amount is a string. Parsing one into a binary float loses money quietly. See Money.
kesAmountis the principal;kesTotalAmountis what moves. This is asell, so the fee comes off and31416.22is what we pay you. Reconcile against the total. See Fees.expiresAtis short and the quote is single-use. Create the order against it promptly; a spent or expired quote is a409.settlementEstimateSecondsis a banded estimate, not a promise. It tells you roughly what to tell your user, not what to time out on.
Amounts here are illustrative
The rate and the fee above are examples, not live figures. Read rate,
kesAmount, kesTotalAmount and expiresAt off your own quote response —
never assume a rate, a fee or a window.
Next
- Sell USDT, receive KES — the full flow
- Webhooks — how you learn an order finished
- Errors — the envelope and the full code catalogue