OnLink
Guides

Send USDT from your wallet

Create an instruction, your registered person confirms it with a one-time code, then it executes. One rail, one consent step, one honest failure model.

This moves USDT out of your wallet to a registered destination wallet. It is the outbound mirror of sending money from your KES account: the same one-time-code consent gate, the same asynchronous settlement, and the same rule that nothing reaches the chain until that code comes back to us.

Creating a send does not move anything

POST /v1/usdt/sends returns 202 with a confirmation — a one-time code sent to your registered confirmation contact, not to your integration. Nothing reaches the chain until that code comes back to us on POST /v1/confirmations/:id.

sequenceDiagram
    autonumber
    accTitle: Instructing a USDT send, confirming it with a code, then settling it
    accDescr: Eight messages between you, OnLink and your registered person. You create a send, naming the destination wallet and the amount. OnLink checks your caps and your ledger balance. OnLink delivers the confirmation code to your registered person. OnLink answers you 202 with the send id and a confirmation carrying its id and expiry. Your registered person reads you the code over your own channel. You post that code to the confirmation. OnLink answers 200 confirmed, then dispatches the transfer on chain. OnLink posts you either the usdt_send.settled or the usdt_send.failed webhook.
    participant You
    participant OnLink
    participant Person as Your registered person

    You->>OnLink: POST /v1/usdt/sends (destinationWalletId, usdtAmount)
    OnLink->>OnLink: check caps and ledger balance
    OnLink->>Person: deliver the confirmation code
    OnLink-->>You: 202 sendId, confirmation { id, expiresAt }
    Person->>You: reads you the code (your own channel — a call, a chat, a desk)
    You->>OnLink: POST /v1/confirmations/:id (code)
    OnLink-->>You: 200 status=confirmed
    OnLink->>OnLink: dispatch on chain
    OnLink-->>You: webhook usdt_send.settled or usdt_send.failed

The same eight steps, written out:

  1. You call POST /v1/usdt/sends with the destination wallet and the amount.
  2. We check the send against your caps and, if your wallet is one we custody (onlink_managed), your USDT ledger balance.
  3. We deliver the one-time confirmation code to your registered confirmation contact — never to your integration.
  4. We answer you 202, carrying the send's id and a confirmation block with its own id and expiresAt.
  5. Your registered person passes you the code over your own channel.
  6. You call POST /v1/confirmations/:id with that code.
  7. We answer 200 with status=confirmed, and only then dispatch the transfer.
  8. We post you one of two webhooks: usdt_send.settled if it completed on chain, or usdt_send.failed if it did not go out.

A send is instructed by your integration and confirmed by your registered confirmation contact, exactly like a KES transfer — see Send money from your KES account for the full reasoning: we never send the code to your integration, and nothing reaches the chain before that code verifies.

1. Register and confirm a destination wallet

The destination must be a wallet you have already registered and confirmed with POST /v1/wallets — see Sell USDT, receive KES for the registration flow, which is shared with this one. Only a active wallet may receive value; one belonging to another partner, or not yet confirmed, is refused the same way as one that does not exist at all — see Errors for why that opacity is deliberate.

2. Create the send

#!/usr/bin/env bash
# Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./usdt-send-create.sh
set -euo pipefail

HOST="https://sandbox.onlink.africa"
METHOD="POST"
PATH_AND_QUERY="/v1/usdt/sends"
BODY='{"destinationWalletId":"fa9f1e6a-0000-4000-8000-000000000050","usdtAmount":"25.500000","partnerReference":"PS-SEND-000123","idempotencyKey":"send-2026-09-16-000123"}'

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
destinationWalletIdThe id of a registered, active wallet of yours (see GET /v1/wallets).
usdtAmountDecimal string, USDT, up to 6 decimal places, greater than zero. Never a JSON number.
partnerReferenceYour own handle, echoed back on every read. Optional, but there is no list-sends endpoint, so record one.
idempotencyKeyRequired. See Idempotency for the replay rule.

Response — 202 Accepted:

{
  "id": "ed9f1e6a-0000-4000-8000-000000000040",
  "status": "awaiting_confirmation",
  "destinationWalletId": "fa9f1e6a-0000-4000-8000-000000000050",
  "usdtAmount": "25.500000",
  "partnerReference": "PS-SEND-000123",
  "createdAt": "2026-09-16T00:00:00.000Z",
  "confirmation": {
    "id": "df9f1e6a-0000-4000-8000-000000000030",
    "channel": "email",
    "deliveredTo": "o***@partner.co.ke",
    "expiresAt": "2026-09-16T00:05:00.000Z",
    "resendAvailableAt": "2026-09-16T00:00:15.000Z"
  }
}

3. Confirm the send

Same endpoint, same shape, as confirming a transfer — POST /v1/confirmations/:id with the code your registered person read you. See step 4 of the transfers guide for the request/response shape; it is identical here because POST /v1/confirmations/:id is one endpoint serving both subject types.

#!/usr/bin/env bash
# Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./usdt-send-confirm.sh
# Replace the confirmation id in PATH_AND_QUERY with the "confirmation.id"
# from your own create response.
set -euo pipefail

HOST="https://sandbox.onlink.africa"
METHOD="POST"
PATH_AND_QUERY="/v1/confirmations/df9f1e6a-0000-4000-8000-000000000030"
BODY='{"code":"482913"}'

TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')"
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"

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 -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"

Response — 200 OK:

{
  "confirmationId": "df9f1e6a-0000-4000-8000-000000000030",
  "subjectType": "usdt_send",
  "subjectId": "ed9f1e6a-0000-4000-8000-000000000040",
  "status": "confirmed",
  "verifiedAt": "2026-09-16T00:02:00.000Z"
}

4. Wait for settlement — and read the failure model carefully

Once confirmed, a send moves through processing and ends at settled or failed:

  • usdt_send.settled — the USDT left your wallet and reached the destination.
  • usdt_send.failed — it did not go out. If we had already reserved the amount against your USDT ledger balance, that reservation is reversed before this event fires — the balance you read afterwards is the balance you had before. failureReason on the send names why; see Errors — USDT sends for the catalogue.

Silence is not failure. Read the send; do not send it again.

If a confirmed send has not settled and has not failed, that is an instruction we cannot yet resolve, not an outcome we are hiding. Read the send with GET /v1/usdt/sends/:id, never instruct a second one for the same purpose. Two sends confirmed against what you believe is the same stuck instruction can both leave your wallet — unlike a duplicate POST with the same idempotencyKey, which safely replays, a second send is a new instruction with its own confirmation and nothing stops it executing alongside the first.

There is also a third event, keyed on the confirmation rather than the send:

  • confirmation.expired — the code's window passed before anyone confirmed it. The send itself moves to expired; nothing was ever sent on chain. Create a new send if it is still wanted.

Statuses you will see on GET /v1/usdt/sends/:id

statusMeaningWebhook
awaiting_confirmationWaiting on the code. Nothing sent on chain yet.none — you already have the 202
confirmedCode verified; handed off for execution.none — you already have the 200 confirm response
processingBeing dispatched, or awaiting the on-chain result.none
settledDone. USDT left your wallet.usdt_send.settled
failedIt did not go out.usdt_send.failed
expiredThe confirmation code's window passed unconfirmed.confirmation.expired

Caps and balance

A send counts against the same rolling 24-hour cap as your orders and transfers — one ceiling across all three, not three independent ones. See Caps and limits.

If your wallet is one we custody (onlink_managed), passing the balance check at creation does not reserve the amount. Nothing is held aside between creation and confirmation. If you have more than one send or order in flight, make sure your ledger balance can cover all of them at once.

Errors worth handling

Every code in this section's shape is catalogued in Errors — USDT sends. The two worth highlighting here:

StatusMeaning
422The destination is not a registered, active wallet of yours, the amount is out of shape, or the send breaches your per-instruction or daily cap.
409USDT_SEND_NOT_CONFIRMABLE — the send already moved past awaiting_confirmation before your confirmation call landed.

On this page