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.
By default, 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 (unless
you have asked us to push codes to your webhook; see Auto-confirm
instructions). Nothing reaches the
chain until that code comes back to us on POST /v1/confirmations/:id. The
one exception is an account OnLink has enabled confirmation waiving for; see
When confirmation is waived.
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, the minimum and the amount's precision — not your ledger balance yet. 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 checks and debits your ledger balance before dispatching 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, minimum and precision
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: check ledger balance, debit, 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. Needs theusdt:writescope, on a credential whose product isusdt_rails(any other product gets403 product_not_supported). - We check the send against your caps, the minimum and the amount's precision. Your ledger balance is not checked here — see Caps and the ledger balance below for when it is.
- We deliver the one-time confirmation code to your registered confirmation contact — not to your integration, unless you have asked for codes by webhook.
- 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. Needs theconfirmations:writescope. - We answer
200withstatus=confirmed. Only now — if your wallet is one we custody (onlink_managed) — do we check and debit your USDT ledger balance, 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 (including a balance shortfall discovered at this step).
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: by default we never send the code to your integration (an account OnLink has switched to receive codes by webhook is the exception — see Auto-confirm instructions), and nothing reaches the chain before that code verifies.
When confirmation is waived
OnLink can enable confirmation waiving for your partner account, on request. When it
is on, POST /v1/usdt/sends creates the send already confirmed, with no
confirmation object in the response (the key is absent, not null), and starts
executing it straight away. There is no code and no
POST /v1/confirmations/:id call to make. You still receive usdt_send.settled or
usdt_send.failed, and no confirmation.code_issued event is sent.
- Handle both answers on create. Accept
confirmedandawaiting_confirmation, and only post a code when there is aconfirmationblock to post it to. - In production, the waiver applies only to a key with an IP allowlist. If your waived
account's API key has no IP
allowlist, the send is not
refused: it gets a code as described on this page, and the
confirmationblock carries"waiverNotApplied": "key_without_ip_allowlist", on the first response only (a repeat of the same request returns the stored send without it). Ask OnLink to add an IP allowlist to that API key. If your account lacks a deliverable channel for the delivery mode OnLink has configured (a confirmation contact email for contact delivery, or a usable webhook for webhook delivery), there is no code to send: the create is refused with503CONFIRMATION_CONTACT_MISSINGorCONFIRMATION_DELIVERY_UNAVAILABLE, and that503carriesdetails.waiverNotApplied: "key_without_ip_allowlist"when the missing allowlist is the cause. Nothing is created. - Registering a destination wallet still needs its code. The waiver covers the
payout, not the wallet registration in step 1, and a send still only reaches an
activewallet. Caps, the minimum and the amount's precision are checked before the send is created, as before.
1. Register and confirm a destination wallet
The destination must be a wallet you have already registered and had
confirmed — see Tron for the full
registration flow. In outline: you call POST /v1/wallets (wallets:write)
to register the address, a partner admin you registered via POST /v1/admins
(admins:write) confirms it with a code we email them via
POST /v1/wallets/:id/confirm (wallets:write), and it becomes active once
OnLink activates it — two independent checks, yours and ours.
Only an active wallet may receive value (GET /v1/wallets, wallets:read,
lists yours with their status). One belonging to another partner, one not yet
active, or one that does not exist at all is refused the same way — 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":"ACME-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, greater than zero. Shaped to allow up to 6 decimal places, but only 2 are ever accepted — a third decimal onward is refused with USDT_SEND_AMOUNT_PRECISION. There is also a floor of 5.000000 USDT, refused below that with USDT_SEND_BELOW_MINIMUM. Never a JSON number. |
partnerReference | Your own handle, echoed back on every read. Optional, but there is no list-sends endpoint, so record one. Up to 64 characters — shorter than the 120 an order allows. |
idempotencyKey | Required, 8–64 characters of letters, digits, ., _, : or -. 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": "ACME-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
If the create response was confirmed with no confirmation object (see When
confirmation is waived), skip this step: there is
no code to post, and the next thing you hear is the webhook.
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 your wallet is one we custody (onlink_managed) and your ledger balance had already been debited for this send, we return that debit. For a transfer that failed at the rail (USDT_RAIL_RETURNED,USDT_RAIL_REFUNDED,USDT_RAIL_CANCELEDorUSDT_RAIL_UNDELIVERABLE), and for a send that failed while we were dispatching it, the return is made before this event fires. A send we recover as never having gone out (USDT_SEND_UNSENT_RECOVERED) is failed first and its amount is returned with the event or shortly after it, so a balance you read the instant the webhook arrives can briefly look short; read it again before you treat it as a shortfall. A transfer the rail reports as an error is different: it is held for review, the send staysprocessingwith your balance still debited, and we resolve it (see the callout below). The return is a ledger credit that can, rarely, fail to post: your balance can then be short by the send amount even though the USDT never left your wallet. Do not assume the balance you read afterwards is the balance you had before: if it looks short, reconcile your USDT balance and contact us with the send id.failureReasonon the send names why it failed — see "Errors worth handling" below for the values you may see.
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. That includes a send
whose transfer we started but could not confirm the outcome of (a timeout, a
dropped connection, an unreadable answer) and one the rail reports as an error
that has to be reviewed before it can be resolved: the USDT may have left your
wallet, so we never mark such a send failed. It stays processing until we
resolve it against the rail's own record, and ends settled, or failed once
the rail itself reports a failure. If a send has been processing for more
than an hour, contact us with its id. 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 |
In sandbox
A sandbox send moves no real USDT and settles, or fails, on its own, usually
within about a minute, while sandbox simulations are enabled. There is nothing to
call. Poll GET /v1/usdt/sends/:id; the read carries
X-OnLink-Money-Mode: simulated. See
Testing in sandbox: Sending USDT to a wallet
for what to expect, and
Sandbox for how to
get a USDT balance to send from.
Caps and the ledger 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, measured in KES-equivalent at the current mid-rate. See Caps and limits.
The cap, the minimum and the amount's precision are checked at creation.
Your ledger balance is not — passing every check on POST /v1/usdt/sends
is not proof the send will settle. If your wallet is one we custody
(onlink_managed), the balance is checked — and debited — only once you
confirm the send, as part of execution, and nothing is reserved between
creation and confirmation. If you have more than one send or order in flight,
make sure your balance can cover all of them at the moment each one confirms,
not only at the moment you created it. A shortfall discovered at that point
ends the send failed with failureReason: "USDT_SEND_INSUFFICIENT_LEDGER_BALANCE"
and nothing debited — it is never a 422 on the confirm call. GET /v1/usdt/balance shows the
USDT you can use now (see Your USDT balance),
and like any balance reading it is not a reservation. GET /v1/usdt/holding-wallet
returns the wallet the send draws from (see Where your USDT is
held); it is not one of the
registered destinations of /v1/wallets.
Errors worth handling
Every code in this section's shape is catalogued in
Errors — USDT sends. The 422s worth naming
individually:
| Code | Meaning |
|---|---|
USDT_SEND_DESTINATION_NOT_ALLOWLISTED | The destination is not a registered, active wallet of yours. |
USDT_SEND_BELOW_MINIMUM | Below the 5.000000 USDT floor. |
USDT_SEND_AMOUNT_PRECISION | More than 2 decimal places. |
USDT_SEND_EXCEEDS_PER_ORDER_CAP | This send alone, in KES-equivalent, exceeds your per-instruction ceiling. |
USDT_SEND_EXCEEDS_DAILY_CAP | This send would push your rolling 24h total past your daily ceiling. |
The 409s and the destination 503:
| Status | Meaning |
|---|---|
409 | USDT_SEND_NOT_CONFIRMABLE — the send already moved past awaiting_confirmation before your confirmation call landed. |
409 | USDT_SEND_DESTINATION_BLOCKED — this destination cannot be used. The response gives no reason and nothing was created; contact us. Retrying will not clear it. |
503 | USDT_SEND_DESTINATION_SCREENING_UNAVAILABLE — we could not check the destination just now. Nothing was created; retry the same request shortly (the same idempotencyKey is fine, since no send was written). |
A destination is checked when you create the send, not only when you register
the wallet, so a wallet that was active when you registered it can still be
refused here later.
failureReason values you may see
failureReason on a failed send is machine-readable. These are the
provider-neutral values you may see:
PARTNER_USDT_SENDS_DISABLED, MONEY_MOVEMENT_PAUSED, PARTNER_NOT_FOUND,
PARTNER_SUSPENDED, USDT_SEND_DESTINATION_NOT_ALLOWLISTED,
USDT_SEND_DESTINATION_ADDRESS_INVALID, DESTINATION_BLOCKED (the destination
can no longer be used; no debit was made and nothing was sent; contact us),
DESTINATION_SCREENING_UNAVAILABLE (we could not finish checking the
destination when the send ran; no debit was made and nothing was sent; create
the send again later, and contact us if it keeps happening),
PARTNER_WALLET_NOT_PROVISIONED,
SEND_SOURCE_WALLET_UNRECOGNISED, SEND_ON_BEHALF_OF_UNRESOLVED,
SEND_SOURCE_PERSIST_FAILED, USDT_SEND_AMOUNT_PRECISION_UNSUPPORTED,
USDT_SEND_BALANCE_READ_FAILED,
USDT_SEND_INSUFFICIENT_LEDGER_BALANCE, USDT_SEND_EXECUTION_FAILED, and
USDT_SEND_UNSENT_RECOVERED (we later proved nothing had left and returned
the debit).
A rail-level rejection carries one of these USDT_RAIL_* values, reflecting
what our on-chain rail actually reported:
USDT_RAIL_REJECTED (the rail validated and refused the transfer),
USDT_RAIL_TRANSPORT_FAILED_BEFORE_SEND (the request never reached the rail),
USDT_RAIL_RETURNED, USDT_RAIL_REFUNDED, USDT_RAIL_CANCELED and
USDT_RAIL_UNDELIVERABLE. You may still read USDT_RAIL_AMBIGUOUS on a send
that failed before 2026-10-14, and USDT_RAIL_ERROR on one that failed before
2026-10-30. A transfer whose outcome we cannot confirm, and a transfer the rail
reports as an error, are no longer failed with them: the send stays
processing (see above).
PROVIDER_REJECTED is the generic fallback for a rail failure we could not
classify more specifically — treat it exactly like USDT_RAIL_REJECTED, not
as a distinct condition worth branching on.
A small number of internal-only values exist for finer distinctions on the
same rail; if you ever see a failureReason not in this list, treat it as
"declined — contact us with the send id."
Auto-confirm instructions
Have confirmation codes pushed to your signed webhook and confirm an instruction from your own code: verify, dedupe, match, confirm. What changes, what to check, and what to do when a push does not arrive.
Open a collection
Mint a reference and USD deposit instructions for one collection, read the fee terms up front, then read the collection back at any time.