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.

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

The same eight steps, written out:

  1. You call POST /v1/usdt/sends with the destination wallet and the amount. Needs the usdt:write scope, on a credential whose product is usdt_rails (any other product gets 403 product_not_supported).
  2. 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.
  3. We deliver the one-time confirmation code to your registered confirmation contact — not to your integration, unless you have asked for codes by webhook.
  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. Needs the confirmations:write scope.
  7. We answer 200 with status=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.
  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 (including a balance shortfall discovered at this step).

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 confirmed and awaiting_confirmation, and only post a code when there is a confirmation block 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 confirmation block 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 with 503 CONFIRMATION_CONTACT_MISSING or CONFIRMATION_DELIVERY_UNAVAILABLE, and that 503 carries details.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 active wallet. 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"
FieldNotes
destinationWalletIdThe id of a registered, active wallet of yours (see GET /v1/wallets).
usdtAmountDecimal 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.
partnerReferenceYour 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.
idempotencyKeyRequired, 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_CANCELED or USDT_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 stays processing with 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. failureReason on 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 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

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:

CodeMeaning
USDT_SEND_DESTINATION_NOT_ALLOWLISTEDThe destination is not a registered, active wallet of yours.
USDT_SEND_BELOW_MINIMUMBelow the 5.000000 USDT floor.
USDT_SEND_AMOUNT_PRECISIONMore than 2 decimal places.
USDT_SEND_EXCEEDS_PER_ORDER_CAPThis send alone, in KES-equivalent, exceeds your per-instruction ceiling.
USDT_SEND_EXCEEDS_DAILY_CAPThis send would push your rolling 24h total past your daily ceiling.

The 409s and the destination 503:

StatusMeaning
409USDT_SEND_NOT_CONFIRMABLE — the send already moved past awaiting_confirmation before your confirmation call landed.
409USDT_SEND_DESTINATION_BLOCKED — this destination cannot be used. The response gives no reason and nothing was created; contact us. Retrying will not clear it.
503USDT_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."

On this page