Errors
One error envelope for every refusal, the codes you branch on, and what is worth retrying.
Every partner-plane failure returns the same JSON shape.
{
"statusCode": 409,
"message": "QUOTE_EXPIRED_OR_CONSUMED",
"error": "Conflict",
"requestId": "req-1756512000000-a1b2c3d"
}Three fields are appended when they apply:
errorCode— present on some refusals, absent on most. On a403insufficient_scopeit names the scope your credential is missing.details— structured context beside a short code, on4xxrefusals only:details.attemptsRemaining,details.status,details.fieldand the like. The catalogue below names each one where it exists.retryAfterSeconds— present only when a refusal supplies one. The rate limit does not: its429carries the wait in theRetry-After-partnerheader and nothing in the body.
Branch on message, not on errorCode
The stable short codes arrive in message. errorCode is only set on
some refusals, so a client that switches on it silently falls through to a
default branch for most of the catalogue. Not every message is a short
code: a validation failure reads Validation failed on: <fields>, a path id
that is not a UUID reads Validation failed (uuid is expected), and a
handful of refusals on quotes, orders, wallets and approvers read as a
sentence — listed under Refusals that read as
sentences. Match those verbatim rather
than assuming every message is an upper-snake-case short code.
requestId is on every error body and is also returned as the X-Request-Id
header. It is the only handle that resolves to your request in our logs — log it
on every non-2xx response and quote it when you
contact us.
Status codes
| Status | Meaning | Retry? |
|---|---|---|
202 | Accepted. The order exists; it has not settled. | — |
400 | The request failed validation — the body, a query parameter, or a path id that is not a UUID. | No. Fix the request. |
401 | Authentication failed. Uniform, with no reason given. | No. See below. |
403 | Your credential is authenticated but not authorised for this endpoint, or a code is locked out. | No. Ask us for the scope. |
404 | No such route, or no such order, transfer, send, collection or wallet for your partner account. | No. |
409 | A conflict with existing state — a spent quote, a reused reference, a hash already attached. | Only after changing something. |
413 | The request body is larger than we accept. | No. Send a smaller body. |
422 | The request breaches one of your caps, or your balance cannot cover it. | No. Fix the amount, or fund the balance, then try again. |
429 | Rate limited, or a confirmation code was asked for again too soon. | Yes, after the wait the response names. |
500 | Something went wrong while handling the request. The message is deliberately generic. | Yes, with backoff and the same partnerReference. Quote requestId. |
502 | A service we depend on answered with an error. | Yes, with backoff. |
503 | Your account readiness, the settlement account, or a platform-wide control is not ready right now. | Depends on the code. See below. |
504 | A service we depend on did not answer in time. | Yes, with backoff. Check the order or transfer before creating again. |
401 is deliberately uninformative
A bad signature, an expired timestamp, a replayed nonce and an unknown key all return the same body:
{
"statusCode": 401,
"message": "An error occurred",
"error": "unauthorized",
"requestId": "req-1756512061412-9f4e2b7"
}Distinguishing them would tell an attacker which half of a guess was right. Debug against the worked example on the authentication page instead.
429 carries its own backoff
{
"statusCode": 429,
"message": "Too Many Requests",
"error": "Too Many Requests",
"requestId": "req-1756512184903-7c1d5a0"
}Read the wait, in seconds, from the Retry-After-partner header. The limit
is enforced by a throttler named partner, and the header carries that name.
The body has no retryAfterSeconds on this refusal, so a client that looks
only at the body backs off for nothing and is refused again. The limit is 120
requests per minute per partner across all your credentials — see
Caps and limits.
The code catalogue
These are the values that arrive in message. Branch on them.
Every row carries the code, the status it comes back on, what it means and what most often causes it, and what to do. Read the last column first: it opens with the retry verdict, because that is the decision your code has to make before anything else.
Quotes
| Code | Status | What it means | What to do |
|---|---|---|---|
quoteId not found. | 404 | No quote with that id belongs to you. This one is a sentence rather than a stable short code — match it exactly as shown, trailing period included. Usually a quoteId copied wrong, or one belonging to a different partner account. An expired quote is QUOTE_EXPIRED_OR_CONSUMED, not this. | No. Take a fresh quote. |
QUOTE_EXPIRED_OR_CONSUMED | 409 | The quote has expired or already made an order. Usually a user who hesitated past expiresAt, or a create retried on a spent quote. | No. Take a fresh quote — the same one refuses forever. |
QUOTE_SIDE_MISMATCH | 409 | A buy quote was presented to the sell route, or the reverse. Usually one code path builds the quote and another picks the route. | No. Quote the side you are creating, then retry. |
USDT_SEND_BELOW_MINIMUM | 422 | POST /v1/quotes for a buy delivered to your own wallet came to less than 5 USDT, the least we can send on-chain. A buy credited to your managed USDT balance has no minimum. The same code is returned again at order create and by a USDT send. | No, not at this size. Quote at least 5.00 USDT. |
USDT_SEND_AMOUNT_PRECISION | 422 | POST /v1/quotes for a buy delivered to your own wallet named a usdtAmount with more than 2 decimal places, which we cannot send on-chain. A quote named by kesAmount never trips this: its USDT is rounded down to 2 places for you. | No. Quote with at most 2 decimal places, or use kesAmount. |
Fees
| Code | Status | What it means | What to do |
|---|---|---|---|
FEE_EXCEEDS_PROCEEDS | 422 | On a sell, the fee equals or exceeds the KES the trade would pay out, so there would be nothing left to send you. Reached on very small sells against a flat fee — see Fees. | No, not at this size. Quote a larger amount. Retrying the same amount refuses identically, and a buy of the same size still prices. |
FEE_PRICING_INVALID | 503 | Your stored pricing cannot be applied, so we will not guess at a fee. Ours to fix, not yours. | Yes, after we fix it. Nothing about your request caused this — contact us; retrying sooner gets the same answer. |
Orders
| Code | Status | What it means | What to do |
|---|---|---|---|
ORDER_EXCEEDS_PER_ORDER_CAP | 422 | This one order is above the per-order ceiling set for your account. | No. Split it, or ask us to review the cap. |
ORDER_EXCEEDS_DAILY_CAP | 422 | It would breach your rolling 24-hour total. Caused by volume earlier in the window, not by this order alone. The window now counts committed USDT sends as well as orders and transfers. | Not yet. Wait for the window to roll, or talk to us. |
ORDER_DAILY_CAP_RATE_UNAVAILABLE | 503 | The daily-cap check needed a live market rate to convert your committed USDT sends into KES, and none was available. Only affects an account with committed sends outstanding. | Yes. Retry shortly. |
ORDER_NOT_BUY_SIDE | 409 | No current route returns this code; it is reserved. GET /v1/orders/{id} returns either side's order. | No. Check the orderId first. |
ORDER_NOT_SELL_SIDE | 409 | A sell-only action was taken against a buy order. Usually the wrong orderId, or one handler serving both sides. | No. Check the orderId first. |
ORDER_NOT_AWAITING_USDT | 409 | A hash was attached to a sell order that is no longer awaiting_usdt — including one paid from your USDT balance, which leaves that status when it is created. | No. Read the order; it has already moved on. See PATCH /v1/orders/{id}. |
EXPECTED_AMOUNT_MISMATCH | 409 | expectedUsdtAmount on a buy order did not equal the quote's usdtAmount. Usually an amount recomputed on your side instead of read off the quote. | No. Pass the quote's usdtAmount verbatim, or omit the field — it asserts, it does not set. |
PARTNER_SUSPENDED | 409 | Your partner account is not active, so it cannot create orders, transfers, USDT sends or collections. A suspension, not a problem with this request. | No. Contact us; retrying will not clear it. |
Account provisioning
Your KES balance account — the one you fund with M-PESA or a bank transfer
(see Receive money in Kenya), and the
one POST /v1/orders/buy debits directly — is derived from your partner account
rather than configured. These codes cover the cases where it is not yet ready.
| Code | Status | What it means | What to do |
|---|---|---|---|
PARTNER_VA_NOT_ALLOCATED | 503 | No KES balance account is linked to your partner account yet, because onboarding is not finished. | No. Ask us to link a verified business, or to open one. |
PARTNER_VA_ACTIVATING | 503 | The account exists but has not finished activating. It resolves on its own. | Yes. The one code here worth a short retry loop. |
PARTNER_VA_SUSPENDED | 503 | Your KES balance account has been suspended. A suspension, not a problem with this request. | No. Contact us; retrying will not clear it. |
PARTNER_NOT_FOUND | 404 | Your partner account could not be resolved. Not something your integration can cause. | No. Contact us with the requestId. |
Buy leg (debit-first)
POST /v1/orders/buy debits your own KES balance the instant the order is
created — see Buy USDT with KES. These codes
cover every way that debit can be refused, in the order the checks run.
| Code | Status | Meaning | What to do |
|---|---|---|---|
SWAPS_DISABLED | 503 | USDT/KES orders are not enabled for your account right now. | Contact us. Nothing about your request is wrong, and retrying will not clear it until the feature is enabled. |
MONEY_MOVEMENT_PAUSED | 503 | All USDT/KES value movement is halted as an incident control. | Do not retry in a tight loop. Wait for us to confirm the incident has cleared. |
USDT_SEND_BELOW_MINIMUM | 422 | The USDT amount is below 5 USDT, the least we can send on-chain. Only for buys delivered to your own wallet; a buy credited to your managed USDT balance has no minimum. Refused when you take the quote, and again before your quote is consumed and before any KES is debited. It is the code a USDT send returns for the same limit. | No, not at this size. Take a new quote for at least 5.00 USDT. The unused quote is untouched. |
USDT_SEND_AMOUNT_PRECISION | 422 | The usdtAmount you named needs more than 2 decimal places, which we cannot send on-chain. Only for buys delivered to your own wallet; a buy credited to your managed USDT balance keeps all 6 places. Refused when you take the quote, and again before your quote is consumed and before any KES is debited. A quote named by kesAmount never trips this: its USDT is rounded down to 2 places for you. | No. Quote with usdtAmount at no more than 2 decimal places (for example "25.50"), or name the amount with kesAmount. |
TREASURY_ACCOUNT_NOT_REGISTERED | 503 | No settlement account is registered for this currency at all. | Contact us with the requestId. Retrying will not clear it. |
TREASURY_ACCOUNT_UNAVAILABLE | 503 | The settlement account could not take the debit. Usually this is before anything happens: your quote is not consumed and no order exists. Rarely it is our final re-check just before the debit: the order was created and rejected, the quote is spent, and nothing was debited. | Retry once with the same quote and partnerReference. If the retry returns 202 with status: "rejected", it was the late case: take a fresh quote and use a new partnerReference. If it refuses again, stop and contact us with the requestId — it will not clear on its own. |
KES_BALANCE_UNREADABLE | 503 | We could not read your KES balance to check it covers the order. | Retry shortly. If it persists, contact us with the requestId. |
INSUFFICIENT_KES_BALANCE | 422 | Your available KES balance is below the quote's kesTotalAmount (principal plus fee). | Fund your KES balance, then retry — this refusal happens before your quote is consumed. |
KES_DEBIT_FAILED | 503 | We did not debit you: the bank cleanly refused the debit, or we stopped before sending it. The order is normally rejected — read it to confirm. | Read the order to confirm, then create a new order against a fresh quote with a new partnerReference — reusing the old one returns the same rejected order, not a fresh attempt. Do not retry this request. |
KES_DEBIT_AMBIGUOUS | 503 | The KES debit result is ambiguous — we cannot tell whether it succeeded or failed, or we cannot record the outcome. Your KES balance may already have been debited. The order stays funds_reserved while we resolve it from the bank's record. | Do not create a new order. The response carries no orderId: re-send the same request with the same partnerReference, which returns the existing order without a second debit, or wait for the order's webhook. |
This table covers the SYNCHRONOUS refusals only
Both KES_DEBIT_FAILED and a late TREASURY_ACCOUNT_UNAVAILABLE (our own
re-check, run immediately before the debit) reject the order rather than
leaving it stranded, and for both of these nothing was ever taken — the bank
cleanly refused the debit, or we stopped before sending it. That is true of
these two codes and only these two. A rejected order you learn about later —
through order.rejected or by polling — can also have been refused after
the bank confirmed your debit, and then your KES was taken and is owed back.
Read kesDebit.status on the order — never assume from the word "rejected"
alone: failed means we hold no record that your KES was taken (contact us
with the orderId if it did leave your balance), reversal_pending means it
was taken and is owed back, reversed means it has been returned. See Buy
USDT with KES.
Sell leg (settlement account check)
POST /v1/orders/sell checks the settlement account before it consumes your
quote, so these refusals leave the quote usable — except a late
TREASURY_ACCOUNT_UNAVAILABLE, described in its row below.
| Code | Status | Meaning | What to do |
|---|---|---|---|
TREASURY_ACCOUNT_NOT_REGISTERED | 503 | No settlement account is registered for this currency at all. | Contact us with the requestId. Retrying will not clear it. |
TREASURY_ACCOUNT_UNAVAILABLE | 503 | Raised before your quote is consumed, or — when paying from your USDT balance — at our final re-check, in which case the order is rejected and nothing was debited. | Retry once with the same partnerReference; if it returns a rejected order, take a fresh quote with a new reference. |
Sell leg (USDT collection)
POST /v1/orders/sell collects the USDT from your own wallet before anything is
owed to you. These codes cover every way that collection can be refused. All of
them happen at create, before any KES is paid out.
| Code | Status | Meaning | What to do |
|---|---|---|---|
CUSTOMER_WALLETS_DISABLED | 503 | Selling from your own wallet is not enabled for your account. Checked before the order is created, any wallet call and any debit: no order was created and your quote was not used. | Contact us. Once it is enabled, retry with the same partnerReference and quote. Nothing about your request is wrong, and nothing was collected. |
USDT_COLLECTION_FAILED | 503 | The collection was refused, or never reached the provider, so nothing was collected from your wallet. The order is now rejected. | Create a new order against a fresh quote with a new partnerReference. Do not retry this request. |
USDT_COLLECTION_OUTCOME_UNKNOWN | 503 | We could not confirm whether the USDT left your wallet. The order is held for us to reconcile; it is not rejected. | Do not create another order or resend USDT. Read the order with GET /v1/orders/{orderId} — re-sending the same request with the same partnerReference returns it — and contact us with the requestId. |
WALLET_OWNERSHIP_CHECK_FAILED | 503 | The wallet named on the order did not pass our ownership check, so no collection was attempted. Your quote is spent and the order exists in awaiting_usdt until it expires. | Stop retrying and contact us with the requestId — this does not clear on its own. To try again, take a fresh quote and use a new partnerReference; reusing the old one returns the stranded order. |
USDT_RAIL_NOT_CONFIGURED | 503 | Our own custody configuration for this rail is incomplete. Entirely ours, and nothing about your request caused it. Your quote is spent and the order exists in awaiting_usdt until it expires. | Contact us with the requestId. Retrying will not clear it. To try again, take a fresh quote and use a new partnerReference; reusing the old one returns the stranded order. |
A refused collection and an unconfirmed one are different answers
USDT_COLLECTION_FAILED means the collection was refused or never left our
side, so nothing was collected and the order is rejected. When the request
timed out or the connection dropped mid-flight we cannot say which way it
went, so we do not reject: the order is held in review and the answer is
USDT_COLLECTION_OUTCOME_UNKNOWN. A held order sends no webhook until we have
reconciled it, and it does not expire on its own. Do not create another order
or resend USDT while it is held.
Sell leg (USDT balance debit)
When your USDT is held as a balance with us, POST /v1/orders/sell debits that
balance at create instead of collecting from a wallet. These codes cover the ways
that debit can be refused. The first is a clean refusal; the other four are
failures to record the debit, and they do not move the order out of
awaiting_usdt.
| Code | Status | Meaning | What to do |
|---|---|---|---|
INSUFFICIENT_USDT_BALANCE | 422 | Your available USDT balance is below the order amount. Nothing was debited. The order is now rejected, and one order.rejected webhook is sent for it. | Top up your USDT balance (the depositAddress on a sell response is yours to fund), then create a new order against a fresh quote with a new partnerReference. Do not retry this request. |
LEDGER_POST_FAILED | 503 | We could not record the debit against your USDT balance — a database failure or timeout while recording it. We cannot say the debit did not land. The order is created and stays awaiting_usdt. | Do not create a second order. Read the order via GET /v1/orders/{orderId}; retrying with the same partnerReference returns that order. Contact us with the requestId if it does not move on. |
LEDGER_SUBJECT_CONFLICT | 409 | A balance entry already exists for this order and does not match this debit. Not something your request can cause. | No. Contact us with the requestId. |
LEDGER_POST_REJECTED | 422 | The balance entry for this debit was refused by our own integrity checks. Ours to look into; nothing about the amount you sent is known to be wrong. | No. Contact us with the requestId; retrying the same request is refused the same way. |
LEDGER_NOT_APPLICABLE | 409 | Your account does not hold a USDT balance with us, so there is nothing to debit. Reached only if the account's wallet arrangement changed after the order was created. | No. Contact us with the requestId. |
An insufficient balance is the only one of these five that is the partner's to
fix. For the other four, read the order before doing anything else: a
LEDGER_POST_FAILED in particular does not tell you whether the debit landed.
Deposits and payouts
| Code | Status | What it means | What to do |
|---|---|---|---|
TX_HASH_ALREADY_ATTACHED | 409 | This order already has a different hash. Re-sending the same one is a 200, not this. | No. One hash per order; it is never overwritten. |
TX_HASH_ALREADY_USED | 409 | That hash is already on another of your orders. Usually one deposit meant to cover two. | No. One deposit funds one order. Send another. |
PAYOUT_ACCOUNT_NOT_PAYABLE | 409 | The payout account cannot take a credit right now — frozen, closed or not yet active — or the id is one you saved before 2026-10-30 that is not your own OnLink KES account (for example one with a bank code). | No. If your OnLink KES account cannot take a credit right now, contact us. If you saved the id earlier, read GET /v1/payout-accounts again and use an id from it. |
MPESA_PUSH_REJECTED | 422 | The bank answered and refused this request. Usually a number that is not M-PESA-registered. | No. The same request cannot succeed. Fix the number. |
MPESA_PUSH_UNAVAILABLE | 503 | We got no answer from the bank. The prompt may or may not have reached the payer. | Carefully — see below. |
These two are the failure modes of
POST /v1/funding/mpesa-push, and the
difference between them is the whole point of having two codes.
MPESA_PUSH_REJECTED is the bank answering. Your request was understood and
declined, so retrying it unchanged will be declined again — and every attempt is a
real request. The most common cause by far is a number that is not M-PESA-registered.
It does not name the upstream condition: a mobile-money rail has failure modes that
are ours to read and not yours to branch on, and a code that changed whenever an
upstream reworded an error would be worse than one that does not.
MPESA_PUSH_UNAVAILABLE is the bank not answering, and it is genuinely
ambiguous: the request may have arrived and prompted the payer before the connection
failed. There is no idempotency key on this route, so an immediate retry can raise a
second prompt on the same handset — and if the payer approves both, two deposits
land. Prefer waiting for credit.received; retry only if no credit arrives. If it
persists, send us the requestId.
Everything else that can refuse a push is a 400 on the body — including an amount
above the M-PESA per-transaction limit, which is refused here rather than forwarded —
or one of the account codes above. The destination is your own account and is
resolved from your partner record, so there is nothing about it for you to get wrong.
403 is not one thing
A 403 from the credential checks carries no code from this catalogue, and it
means one of two different refusals — read error to tell them apart. In both,
message is a sentence rather than a short code:
insufficient_scope— your credential is authenticated but was not granted the scope this route needs.errorCodenames the missing scope, andmessagerepeats it (This credential is not authorised for this endpoint. Required scope: orders:write.), so the refusal tells you what to ask for. See Products and scopes.product_not_supported— your partner account's product does not offer this route at all, whatever scopes your credential holds. This deliberately does not enumerate which products the route does accept — that would describe another product's API surface to a credential that holds none of it. Ask us rather than requesting a scope; no scope grant fixes this one.
Two other routes return a 403 of their own, and neither is a credential
refusal: CONFIRMATION_LOCKED (see Confirmations) and the
wallet-approval refusals under Refusals that read as
sentences.
Ignore codes you do not recognise
We may add codes. Treat an unknown message on a 4xx as a non-retryable
refusal, log it with its requestId, and surface it rather than retrying —
the safe default when you do not know why something was refused.
Transfers
| Code | Status | Meaning | What to do |
|---|---|---|---|
PARTNER_TRANSFERS_DISABLED | 503 | Outbound transfers are not enabled for your account. | Contact us. Retrying will not clear it. |
MONEY_MOVEMENT_PAUSED | 503 | All partner value movement is halted. | Retry later; contact us if it persists. |
TRANSFER_AMOUNT_INVALID | 422 | The amount is zero or not a positive amount with at most two decimals. | Send a positive amount. |
TRANSFER_CURRENCY_NOT_ENABLED | 422 | Your account cannot send this currency on any rail: USD payouts are for Collections accounts, and a USDT Rails account sends KES only (details.currency, details.enabledCurrencies). Also returned by a batch, for the whole batch. Nothing was written. | Send KES, or contact us about a Collections account. |
TRANSFER_RAIL_CURRENCY_UNSUPPORTED | 422 | The rail cannot pay this currency: USD over mpesa, pesalink or internal, or a USD SWIFT payout to an account in another currency (details.rail, details.currency, details.destinationCurrency). | Send USD over rtgs, eft or swift to a USD account, or send KES. |
TRANSFER_DESTINATION_INVALID | 422 | The destination does not fit the rail, or the bank could not resolve it (details.field / details.providerCode). | Fix the named field; do not retry the same destination unchanged. |
TRANSFER_NAME_MISMATCH | 422 | The account holder's name we resolved does not match accountName (details.resolvedName). | Check the name with your payee; retry with the resolved name only if you are sure. |
TRANSFER_EXCEEDS_PER_ORDER_CAP | 422 | The transfer is larger than your per-instruction ceiling (a USD transfer at its KES equivalent). | Split it. |
TRANSFER_EXCEEDS_DAILY_CAP | 422 | The transfer would breach your rolling 24-hour ceiling (orders, transfers and USDT sends together; USD transfers at their KES equivalent). | Wait for the window to roll, or talk to us. |
TRANSFER_INSUFFICIENT_BALANCE | 422 | The account the transfer pays from (KES, or USD for a USD transfer) does not hold amount plus fee. | Fund the account and retry. |
TRANSFER_DAILY_CAP_RATE_UNAVAILABLE | 503 | The cap check needed a live market rate (to count a USD transfer, or your committed USDT sends, in KES) and none was available. Also returned by a batch. | Retry shortly. Nothing about your request is wrong. |
TRANSFER_IDEMPOTENCY_CONFLICT | 409 | This idempotencyKey was used for a different request. | Use a new key; the original transfer stands. |
TRANSFER_NOT_FOUND | 404 | No transfer with that id belongs to you. | Check the id. |
TRANSFER_NOT_CONFIRMABLE | 409 | The transfer is no longer awaiting confirmation (details.status). | Read the transfer; it has moved on. |
Batch transfers
A batch's own per-item outcomes reuse every code in the table above
(TRANSFER_NAME_MISMATCH, TRANSFER_EXCEEDS_DAILY_CAP, …) in each item's own
failureCode — those are not restated here. Only the batch-level facts are new.
| Code | Status | Meaning | What to do |
|---|---|---|---|
PARTNER_BATCH_TRANSFERS_DISABLED | 503 | Batch transfers are not enabled for your account. | Contact us. Retrying will not clear it. |
MONEY_MOVEMENT_PAUSED | 503 | All partner value movement is halted. | Retry later; contact us if it persists. |
BATCH_TOO_LARGE | 422 | The batch carries more items than the declared per-request limit. | Split it into smaller batches. |
BATCH_CURRENCY_MIXED | 422 | The items send more than one currency (details.currencies). A batch pays from one account under one code. | Submit one batch per currency; nothing was written. |
BATCH_NO_VALID_ITEMS | 422 | Every item in the batch failed per-item validation. details.items gives each item's idempotencyKey and failureCode. | Read details.items, fix them and resubmit; no code was issued. |
BATCH_NOT_FOUND | 404 | No batch with that id belongs to you. | Check the id. |
BATCH_NOT_CONFIRMABLE | 409 | The batch is no longer awaiting confirmation (details.status). | Read the batch; it has moved on. |
USDT sends
| Code | Status | Meaning | What to do |
|---|---|---|---|
PARTNER_USDT_SENDS_DISABLED | 503 | Outbound USDT sends are not enabled for your account. | Contact us. Retrying will not clear it. |
USDT_SEND_DESTINATION_NOT_ALLOWLISTED | 422 | The destination is not a registered, active wallet of yours. Also returned for a wallet belonging to another partner — the two are indistinguishable on purpose. | Register the destination with POST /v1/wallets and confirm it, then retry. |
USDT_SEND_DESTINATION_BLOCKED | 409 | This destination cannot be used. The response gives no reason. | Contact us. Retrying will not clear it. |
USDT_SEND_DESTINATION_SCREENING_UNAVAILABLE | 503 | We could not check the destination just now. Nothing was created. | Retry the same request shortly. |
USDT_SEND_BELOW_MINIMUM | 422 | The amount is below our minimum for a USDT send. | Send a larger amount. |
USDT_SEND_AMOUNT_PRECISION | 422 | The amount needs more than 2 decimal places, which we cannot represent for this currency. | Round to at most 2 decimal places and retry. |
USDT_SEND_EXCEEDS_PER_ORDER_CAP | 422 | The amount alone, in KES-equivalent at the current mid-rate, is above your per-instruction ceiling. | Split it. |
USDT_SEND_EXCEEDS_DAILY_CAP | 422 | It would breach your rolling 24-hour KES-equivalent ceiling (orders, transfers and sends together). | Wait for the window to roll, or talk to us. |
USDT_SEND_RATE_UNAVAILABLE | 503 | The current USD:KES mid-rate needed to check your caps could not be read. | Retry shortly. |
USDT_SEND_NOT_CONFIRMABLE | 409 | The send is no longer awaiting confirmation (details.status). | Read the send; it has moved on. |
USDT_SEND_NOT_FOUND | 404 | No send with that id belongs to you. | Check the id. |
USDT_SEND_DESTINATION_NOT_ALLOWLISTED follows the money-movement exception in
our own conventions: the destination wallet is the thing being verified, so this
code deliberately does not distinguish "does not exist" from "belongs to someone
else" from "not yet active" — the same opacity TRANSFER_NAME_MISMATCH's sibling
codes do not need, because a wallet is a machine identifier and a bank account
name is not.
An insufficient balance is NOT a create-time refusal
Unlike the buy and sell legs, a send's onlink_managed-wallet ledger balance
is checked at execute time, after your confirmation — never at create. A
shortfall there does not raise an HTTP error; it ends the send failed with
USDT_SEND_INSUFFICIENT_LEDGER_BALANCE in failureReason. Fund the wallet
and start a new send.
A send that fails after confirmation carries its reason in failureReason on
GET /v1/usdt/sends/:id, not as an HTTP error — see
Send USDT from your wallet for the
full failure model and why some failures reverse a ledger debit while others
deliberately do not.
Two of those reasons concern the destination, and they ask different things of
you. DESTINATION_BLOCKED means the destination can no longer be used: contact
us; retrying will not clear it. DESTINATION_SCREENING_UNAVAILABLE means we
could not finish checking the destination when the send ran: nothing was
debited or sent, so retry later by creating the send again.
USDT holding wallet
| Code | Status | Meaning | What to do |
|---|---|---|---|
HOLDING_WALLET_NOT_ALLOCATED | 503 | No holding wallet address has been allocated for your account yet, so there is no address to return. | Contact us. It is not self-serve, and retrying will not clear it until we have set it up. Nothing was affected. |
Returned by GET /v1/usdt/holding-wallet.
The response never carries an empty address, so an integration cannot send USDT to
nowhere. errorCode carries the code, and message starts with it, followed by
the next step.
Collections
| Code | Status | Meaning | What to do |
|---|---|---|---|
PARTNER_COLLECTIONS_DISABLED | 503 | Collections is not enabled for your account. | Contact us. Retrying will not clear it. |
COLLECTIONS_WALLET_NOT_PROVISIONED | 503 | Your account is not yet fully set up to accept collections. | Contact us; nothing about your request caused this. |
COLLECTIONS_RAIL_UNAVAILABLE | 503 | We could not open your deposit account right now. | Retry shortly; contact us if it persists. |
COLLECTION_VIRTUAL_ACCOUNT_PROVISION_IN_FLIGHT | 409 | A create for this exact collection is already being processed. | Wait briefly, then read the collection rather than creating another. |
COLLECTION_VIRTUAL_ACCOUNT_RECONCILIATION_REQUIRED | 409 | We could not safely reuse or create the deposit instructions for this collection. | Contact us with the requestId; do not retry this request. |
COLLECTION_STATIC_MEMO_PROVISION_IN_FLIGHT | 409 | A create for this exact collection is already being processed. | Wait briefly, then read the collection rather than creating another. |
COLLECTION_STATIC_MEMO_RECONCILIATION_REQUIRED | 409 | We could not safely reuse or create the deposit instructions for this collection. | Contact us with the requestId; do not retry this request. |
COLLECTION_CURRENCY_UNSUPPORTED | 422 | Only USD is supported today. | Send currency: "USD". |
COLLECTION_NOT_FOUND | 404 | No collection with that id belongs to you. | Check the id. |
COLLECTION_NOT_OPEN | 409 | The collection is no longer open; details.status names its status. Reserved for editing, cancelling and sharing a collection, which are not available on /v1 yet; no /v1 route returns it today. | Read the collection; do not retry. |
NOTHING_TO_UPDATE | 400 | An edit named no field to change. Reserved for editing a collection, which is not available on /v1 yet; no /v1 route returns it today. | Send at least one field to change. |
INVALID_IDEMPOTENCY_KEY | 400 | The Idempotency-Key header is not 8 to 64 characters of A-Z, a-z, 0-9, _ and -. Nothing was created. | Send a valid key, or omit the header. |
IDEMPOTENCY_KEY_REUSED | 422 | That idempotency key was already used with different details, or the collection it created has been edited since (the key is compared with the collection as it stands now). Carries no details. | Retry only the identical request while the collection is unedited; otherwise read the collection, and use a new key for a new create. |
expectedAmount on POST /v1/collections is advisory only — it is stored and
echoed back when you read the collection, and that is all it does. Nothing on
this API ever compares it against the real deposit, so a mismatch produces no
error, no event and no field telling you it happened.
Conversions
Every refusal below is returned before anything is debited: a refused
POST /v1/conversions moved no money and created no conversion.
| Code | Status | Meaning | What to do |
|---|---|---|---|
PARTNER_CONVERSIONS_DISABLED | 503 | Conversions are not enabled right now. | Contact us. Retrying will not clear it. |
CONVERSION_PAIR_UNSUPPORTED | 422 | Only from: "USD" to to: "KES" is supported. | Send USD and KES. |
CONVERSION_AMOUNT_INVALID | 422 | amount is not a positive decimal string with at most 2 decimal places. | Send an amount such as "100.00". |
CONVERSION_AMOUNT_BELOW_MINIMUM | 422 | amount is below USD 1.00. | Convert at least USD 1.00. |
CONVERSION_AMOUNT_ABOVE_MAXIMUM | 422 | amount is above the maximum for one conversion, which is the same for every partner. | Split it into several conversions, or contact us for the current maximum. |
CONVERSION_INSUFFICIENT_BALANCE | 422 | Your USD account holds less than amount. | Convert a smaller amount, or wait for more USD to land. |
CONVERSION_RATE_UNAVAILABLE | 503 | No exchange rate could be read right now. | Retry shortly; contact us if it persists. |
CONVERSION_BALANCE_UNAVAILABLE | 503 | Your USD balance could not be read right now. | Retry shortly with the same idempotencyKey. |
CONVERSIONS_UNAVAILABLE | 503 | Conversions are temporarily unavailable. Nothing about your request is wrong, and nothing moved. | Retry shortly with the same idempotencyKey; contact us with the requestId if it persists. |
MONEY_MOVEMENT_PAUSED | 503 | All partner value movement is halted. | Retry later; contact us if it persists. |
IDEMPOTENCY_KEY_REUSED | 422 | That idempotencyKey was already used for a conversion with different details. | Retry only the identical request; use a new key for a new conversion. |
CONVERSION_NOT_FOUND | 404 | No conversion with that id belongs to you. | Check the id. |
Sandbox simulations
These come from the two sandbox-only routes that act as if money arrived,
POST /v1/simulations/collections/{id}/deposits and
POST /v1/simulations/orders/{id}/usdt-receipt. Outside the sandbox those routes
answer a plain 404, exactly like a path that does not exist, so you never see
these codes there.
| Code | Status | Meaning | What to do |
|---|---|---|---|
SIMULATION_AMOUNT_INVALID | 400 | The amount is not a positive decimal with the number of places the route allows. You normally meet this refusal first as Validation failed on: amount; this is the same rule. | No. Send a decimal string: at most 2 places and no more than 10000.00 for a collection, at most 6 for USDT. |
COLLECTION_NOT_OPEN | 409 | The collection is not open, so there is nothing for a payment to settle. Usually it was already paid, or it expired. | No. Read the collection to see its status, or open a new one. |
COLLECTION_NOT_PROVISIONED | 409 | The collection has no deposit instructions yet, so a payment has nowhere to land. | Not yet. Read the collection until its deposit instructions appear, then retry. Contact us if they never do. |
ORDER_NOT_FOUND | 404 | No order with that id belongs to you. | No. Check the id. |
ORDER_TX_HASH_NOT_ATTACHED | 409 | The sell order has no transaction hash attached, so a deposit has nothing to match. | Not yet. Attach the hash with PATCH /v1/orders/{id}, then retry. |
ORDER_USDT_COLLECTED_AUTOMATICALLY | 409 | The USDT for this order is collected from your customer wallet for you, so there is no deposit to simulate. | No. Read the order; it moves on without a simulated deposit. |
SIMULATION_NOT_CONFIGURED | 503 | The simulation cannot run for this object right now. For a collection, the usual cause is that, while sandbox crediting is enabled, it was created before sandbox money was simulated; otherwise it is ours to fix. | For a collection: create a new one and simulate that. Otherwise yes, after we fix it; contact us with the requestId. |
SIMULATION_UPSTREAM_UNAVAILABLE | 503 | We could not complete the simulated payment, and nothing is known to have happened. | Yes. Retry shortly. For a USDT deposit, repeat the amount you sent the first time. |
ORDER_NOT_SELL_SIDE and ORDER_NOT_AWAITING_USDT (see Orders) also
come from the USDT deposit route, with the same meaning: the order is a buy
order, or it is no longer waiting for a deposit.
Confirmations
| Code | Status | Meaning | What to do |
|---|---|---|---|
CONFIRMATION_NOT_FOUND | 404 | No confirmation with that id belongs to you. | Check the id from the 202 response. |
CONFIRMATION_CODE_INVALID | 422 | The code is wrong (details.attemptsRemaining). | Ask your registered person for the code again; do not guess. |
CONFIRMATION_LOCKED | 403 | Too many wrong codes (details.retryAfterSeconds). | Wait, then request a new code. Contact us to have the lock cleared sooner. |
CONFIRMATION_EXPIRED | 409 | The code's 5-minute window passed. | Request a new code with /resend. |
CONFIRMATION_ALREADY_VERIFIED | 409 | This confirmation was already accepted. | Read the transfer; nothing more to confirm. |
CONFIRMATION_CANCELLED | 409 | The instruction was cancelled before it was confirmed. | Create a new transfer if still wanted. |
CONFIRMATION_RESEND_TOO_SOON | 429 | The resend ladder has not elapsed (retryAfterSeconds, Retry-After). | Wait the stated seconds. |
CONFIRMATION_CONTACT_MISSING | 503 | Your partner account has no confirmation contact registered. | Ask us to set one; nothing can be confirmed until then. |
CONFIRMATION_DELIVERY_UNAVAILABLE | 503 | Your account receives confirmation codes by webhook, but no working webhook is configured. Nothing was created. | Contact OnLink support. See Auto-confirm instructions. |
Confirmation codes carry extra context
CONFIRMATION_CODE_INVALID carries details.attemptsRemaining;
CONFIRMATION_LOCKED and CONFIRMATION_RESEND_TOO_SOON carry a wait in
seconds.
A waived account may get no confirmation at all
When OnLink has enabled confirmation waiving for your account, a create can
return status: "confirmed" with no confirmation object. There is nothing
to confirm, and a confirm call against such a payout answers
CONFIRMATION_ALREADY_VERIFIED. In production, a waived account whose API key
has no IP allowlist is still asked for a code, and its confirmation block
carries waiverNotApplied: "key_without_ip_allowlist" on the first response
only (a repeat of the same request returns the stored payout without it); ask
us to add an IP allowlist to that key. If the account also has no confirmation
contact (or no working webhook), there is no code to send, so the create is
refused CONFIRMATION_CONTACT_MISSING or CONFIRMATION_DELIVERY_UNAVAILABLE
(503) and nothing is created. When the cause is the missing allowlist, that
503 carries details.waiverNotApplied: "key_without_ip_allowlist". See
When confirmation is
waived.
Withdrawal addresses
Returned by POST /v1/wallets/{id}/confirm when the address is yours and has
already moved past the step this call performs, so there is nothing left to
confirm. Both carry details.status (the address's current status) and
details.nextStep (a sentence saying what to do).
| Code | Status | Meaning | What to do |
|---|---|---|---|
WALLET_ALREADY_CONFIRMED | 409 | Your approver's confirmation was already recorded. details.status is pending_onlink_approval (awaiting OnLink approval, not yet usable for delivery) or active (already usable). | No. Do not register the address again and do not confirm again. Read GET /v1/wallets to follow its status; only active can receive value. |
WALLET_REVOKED | 409 | The address was revoked, which is terminal. details.status is revoked. | No. Register a different address if you need one. |
A code that is merely expired, cancelled or used up still answers 403, as does
an id that is not one of your wallets (see Refusals that read as
sentences); only your own wallets get the more
specific 409.
Refusals that read as sentences
These come back in message as a sentence, not a short code, so match them
verbatim or on their opening words. Where a value is shown in angle brackets,
it is filled in with yours.
Quotes and orders
| Message | Status | What it means | What to do |
|---|---|---|---|
Provide exactly one of kesAmount or usdtAmount. | 400 | A quote takes one amount, not both and not neither. | No. Send one. |
<field> must be a positive decimal string with at most <n> decimal places. | 400 | An amount is not a decimal string, is negative, or has too many places. | No. Send the amount as a string, e.g. "10000.00". |
<field> must be greater than zero. | 400 | An amount is zero. | No. |
Amount is too small to quote at the current rate. | 400 | The amount rounds to nothing on the other side of the trade. | No. Quote a larger amount. |
partnerReference must not be empty. | 400 | An order was created with a blank partnerReference. | No. Send your own reference. |
expectedUsdtAmount must be a positive decimal string with at most 6 decimal places. | 400 | The optional assertion on a buy order is malformed. | No. Pass the quote's usdtAmount verbatim, or omit it. |
payoutAccountId not found. | 404 | No payout account with that id belongs to you. | No. Read GET /v1/payout-accounts and pick one of those. |
Order not found. | 404 | No order with that id belongs to you. Another partner's order reads exactly the same. | No. Check the id. |
partnerReference is already used by a buy order. / …by a sell order. | 409 | A reference names one order across both sides: it was already used on the other side. | No. Use a fresh reference for this side. |
A live rate is not available. Quotes are not issued on a degraded rate source. | 503 | We will not quote when we cannot stand behind the rate. | Yes. Retry shortly. |
A usable rate is not available right now. | 503 | The same, found while pricing. | Yes. Retry shortly. |
A USDT deposit address has not been allocated for your account yet. | 503 | Your account has no USDT deposit address yet. Also returned by GET /v1/funding, where the message begins with this sentence and carries more text after it. | No. Contact us; retrying will not clear it. |
Wallets and approvers
A withdrawal address is registered, confirmed by one of your approvers with a
one-time code (an approver is registered with POST /v1/admins), then
approved by us.
| Message | Status | What it means | What to do |
|---|---|---|---|
Unsupported chain '<chain>'. Only tron is supported. | 400 | Only Tron addresses can be registered today. | No. Send chain: "tron". |
Unsupported asset '<asset>'. Only USDT is supported. | 400 | Only USDT addresses can be registered today. | No. Send asset: "USDT". |
Address is not a valid tron address (base58check, checksum verified). | 400 | The address fails its checksum. We refuse here because a mistyped address is an unrecoverable loss. | No. Copy the address again from its source. |
Register an administrator (POST /v1/admins) before adding a withdrawal address — approval codes are sent to a registered administrator. | 409 | There is nobody to send the approval code to. | Register an approver, then register the address. |
Incorrect approval code. | 400 | The code does not match. | Ask for the code again; do not guess. |
Too many incorrect codes. This approval code has been cancelled. | 403 | The attempts for this code are used up. | Register the same address again to have a new code. |
This approval code is no longer usable., usually followed by Register the same address again to have a new code sent. | 403 | The code expired, was cancelled, or is for a wallet that is not one of yours. We do not say which on purpose. A wallet of yours that is already confirmed or revoked gets a 409 instead (see Withdrawal addresses). | Register the same address again to have a new code. |
Wallet not found. | 404 | No wallet with that id belongs to you. | No. Check the id. |
Administrator not found., from DELETE /v1/admins/{id} | 404 | No approver with that id belongs to you. | No. Check the id. |
Administrator email must not contain whitespace. / Administrator email is not a valid address. / Administrator email must be on the <domain> domain. | 400 | On POST /v1/admins: the email is malformed, or is not on your organisation's domain. | No. Fix the address. |
Anything else
| Message | Status | What it means | What to do |
|---|---|---|---|
Validation failed on: <fields> | 400 | The named body or query fields failed validation. | No. Fix the named fields. |
Validation failed (uuid is expected) | 400 | A path id is not a UUID. | No. Check the id. |
Request payload is too large | 413 | The body is larger than we accept. | No. Send a smaller body. |
Failed to record rejection for … | 503 | We could not record a refusal on an order we had already created. | Yes. Read the order before creating another one. |
What is worth retrying
429— yes, after the interval in theRetry-After-partnerheader.5xxand network timeouts — yes, with backoff, and reuse the samepartnerReference. That is what makes a retry safe: the same reference returns the existing order rather than creating a second.- Everything else — no. A
4xxmeans the request will be refused the same way until something changes.
Send USDT from your wallet to a registered destination POST
Validates the destination against your wallet allowlist (only an active wallet may receive value), checks the amount against our minimum and your caps, then writes the instruction and sends a confirmation code to your registered confirmation contact, or to your webhook as confirmation.code_issued if OnLink has switched your account to webhook delivery. Nothing reaches the chain until that code is verified via POST /v1/confirmations/:id, unless OnLink has enabled confirmation waiving for your account and, in production, the API key has an IP allowlist, in which case the send is returned already confirmed and is executed immediately.
Changelog
What changed, and what counts as a breaking change.