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"
}Two fields are appended when they apply:
errorCode— present on some refusals, absent on most.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. Validation failures are the one
exception to the short-code shape: they read Validation failed on: <fields>.
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 body failed validation. | 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. | No. Ask us for the scope. |
404 | No such route, or no such order 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. |
422 | The order breaches one of your caps, or your KES balance cannot cover it. | No. Fix the amount, or fund your KES balance, then create a new order. |
429 | Rate limited. | Yes, after the Retry-After-partner header. |
503 | Your account readiness, the settlement account, or a platform-wide control is not ready right now. | Depends on the code. See below. |
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 |
|---|---|---|---|
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. |
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 | A buy-only action was taken against a sell order. Usually the wrong orderId, or one handler serving both sides. | 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 an order no longer awaiting a deposit. Often a retry that raced the first attach. | No. Read the order; it has already moved on. |
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 cannot create orders. A suspension, not a problem with this request. | No. Contact us; retrying will not clear it. |
Account provisioning
Your KES collection account — the number a payer sends M-PESA or a bank transfer to on the buy leg — 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 collection 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 collection 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 | Partner USDT/KES swaps are disabled platform-wide right now. | Retry later. Nothing about your request is wrong. |
KILL_SWITCH_ENGAGED | 503 | An operator has halted all USDT/KES value movement as an incident control. | Do not retry in a tight loop. Wait for us to confirm the incident has cleared. |
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 quoted amount. | Fund your KES balance, then retry — this refusal happens before your quote is consumed. |
TREASURY_ACCOUNT_UNAVAILABLE | 503 | The settlement account was unavailable — either checked before or immediately before the debit. It may be transiently unavailable, or its registration has been retired or replaced entirely. | Retry once, briefly. If it recurs, stop retrying and contact us with the requestId — a retired registration will not clear on its own. |
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. |
KES_DEBIT_FAILED | 503 | We attempted the debit and the bank cleanly refused it — a validated business rejection, not a timeout. The order is now rejected. | Read the order to confirm, then create a new order against a fresh quote. 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 retry. Read the order via GET /v1/orders/{orderId} to see the outcome once we have resolved it. |
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 debit
was refused before it reached the bank, or never attempted at all. 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 arrive after the
bank accepted your debit, and that case may carry a reversal in progress
rather than nothing having moved. See Buy USDT with
KES for that distinction,
and read kesDebit.status on the order — never assume from the word
"rejected" alone.
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 | The partner-customer wallets rail is switched off right now. Checked first, before any wallet call and any debit. | Retry later. Nothing about your request is wrong, and nothing was collected. |
INSUFFICIENT_USDT_BALANCE | 422 | Your available USDT balance is below the order amount. The order is now rejected. | Fund the wallet, then create a new order against a fresh quote. |
USDT_COLLECTION_FAILED | 503 | We attempted the collection and it was refused. The order is now rejected. | Read the order to confirm, then create a new order against a fresh quote. Do not retry this request. |
WALLET_OWNERSHIP_CHECK_FAILED | 503 | The wallet named on the order did not pass our ownership check, so no collection was attempted. | Stop retrying and contact us with the requestId — this does not clear on its own. |
BRIDGE_NOT_CONFIGURED | 503 | Our own custody configuration for this rail is incomplete. Entirely ours, and nothing about your request caused it. | Contact us with the requestId. Retrying will not clear it. |
A refusal here means nothing was collected
Every code in this table is raised before or instead of a successful
collection, so your USDT balance is untouched in all five cases. The two that
move the order to rejected (INSUFFICIENT_USDT_BALANCE and
USDT_COLLECTION_FAILED) do so precisely because the attempt is finished —
there is nothing outstanding to resolve, and a new order is the way forward.
Deposits and payouts
| Code | Status | What it means | What to do |
|---|---|---|---|
TX_HASH_ALREADY_ATTACHED | 409 | This order already has a hash. Usually an attach retried after the first one succeeded. | No. One hash per order; the order is already funded. |
TX_HASH_ALREADY_USED | 409 | That hash is attributed to another order. 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 — closed, frozen or otherwise unable. | No. Use another registered account, or contact us. |
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.
Scope
A 403 carries no code from this catalogue. error is the constant
insufficient_scope and errorCode names the scope the route needed, so the
refusal tells you what to ask for. See
Products and scopes.
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 in this environment yet. | Contact us. Retrying will not clear it. |
KILL_SWITCH_ENGAGED | 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 KES amount with at most two decimals. | Send a positive amount. |
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. | Split it. |
TRANSFER_EXCEEDS_DAILY_CAP | 422 | The transfer would breach your rolling 24-hour ceiling (orders and transfers together). | Wait for the window to roll, or talk to us. |
TRANSFER_INSUFFICIENT_BALANCE | 422 | Your KES account does not hold amount plus fee. | Fund the account and retry. |
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 in this environment yet. | Contact us. Retrying will not clear it. |
BATCH_TOO_LARGE | 422 | The batch carries more items than the declared per-request limit. | Split it into smaller batches. |
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 in this environment yet. | 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_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_INSUFFICIENT_LEDGER_BALANCE | 422 | Your USDT ledger balance does not cover the send (onlink_managed wallets only). | Fund the wallet and retry with a new send. |
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.
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.
Collections
| Code | Status | Meaning | What to do |
|---|---|---|---|
PARTNER_COLLECTIONS_DISABLED | 503 | Collections are not enabled in this environment yet. | 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. |
BRIDGE_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 confirm this collection's deposit account was opened only once. | 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 confirm this collection's deposit reference was opened only once. | 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. |
expectedAmount on POST /v1/collections is advisory only — a mismatch against
the real deposit is reported later (never as an error on this route).
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 | An operator cancelled the instruction 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 codes carry extra context
CONFIRMATION_CODE_INVALID carries details.attemptsRemaining;
CONFIRMATION_LOCKED and CONFIRMATION_RESEND_TOO_SOON carry a wait in
seconds.
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 person. Nothing reaches the chain until that code is verified via POST /v1/confirmations/:id.
Changelog
What changed, and what counts as a breaking change.