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.failedThe same eight steps, written out:
- You call
POST /v1/usdt/sendswith the destination wallet and the amount. - We check the send against your caps and, if your wallet is one we custody
(
onlink_managed), your USDT ledger balance. - We deliver the one-time confirmation code to your registered confirmation contact — never to your integration.
- We answer you
202, carrying the send'sidand aconfirmationblock with its ownidandexpiresAt. - Your registered person passes you the code over your own channel.
- You call
POST /v1/confirmations/:idwith that code. - We answer
200withstatus=confirmed, and only then dispatch the transfer. - We post you one of two webhooks:
usdt_send.settledif it completed on chain, orusdt_send.failedif it did not go out.
The consent model is identical to transfers
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"| Field | Notes |
|---|---|
destinationWalletId | The id of a registered, active wallet of yours (see GET /v1/wallets). |
usdtAmount | Decimal string, USDT, up to 6 decimal places, greater than zero. Never a JSON number. |
partnerReference | Your own handle, echoed back on every read. Optional, but there is no list-sends endpoint, so record one. |
idempotencyKey | Required. 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.failureReasonon 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 toexpired; 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
status | Meaning | Webhook |
|---|---|---|
awaiting_confirmation | Waiting on the code. Nothing sent on chain yet. | none — you already have the 202 |
confirmed | Code verified; handed off for execution. | none — you already have the 200 confirm response |
processing | Being dispatched, or awaiting the on-chain result. | none |
settled | Done. USDT left your wallet. | usdt_send.settled |
failed | It did not go out. | usdt_send.failed |
expired | The 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:
| Status | Meaning |
|---|---|
422 | The 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. |
409 | USDT_SEND_NOT_CONFIRMABLE — the send already moved past awaiting_confirmation before your confirmation call landed. |
Send a batch of transfers
Submit N transfers in one call, consent to the whole batch with a single code, and get a per-item outcome. A loop over the single-transfer behaviour behind one confirmation.
Open a collection
Mint a reference and a dedicated USD deposit account for one collection, read the fee terms up front, then read the collection back at any time.