Common Error Types
API Errors
HTTP status codes and their meanings:Business Logic Errors
Payment-specific decline scenarios and the codes that carry them:- Insufficient funds: your balance cannot cover the payout or conversion, including fees (
INSUFFICIENT_BALANCE, see Legacy shape) - Invalid recipient details: the beneficiary’s details are incomplete or invalid for the rail (
RECIPIENT_DETAILS_INVALID, or a400with adetailsarray at submit time) - Limits: a per-transaction, daily, or corridor limit would be exceeded (
LIMIT_EXCEEDED) - Payouts paused: payouts are not currently permitted for your workspace (
PAYOUT_NOT_PERMITTED) - Rail unavailable: the payout could not be completed on the selected rail right now (
PAYOUT_PROVIDER_UNAVAILABLE) - Compliance holds: a payout can fail after screening; held funds are returned to your balance automatically
Error Response Structure
Zuba is converging on RFC 9457 problem details. Errors arrive in one of three shapes, distinguishable by their fields.Problem details (primary)
Migrated endpoints respond withContent-Type: application/problem+json and a flat envelope (there is no nested error object):
Legacy shape
Endpoints not yet migrated return the legacy top-level shape:message. A payout or conversion your balance cannot cover returns this 400, identically in the sandbox and in production. Its stable code is in code. message is the endpoint’s own text: Insufficient balance on payouts, which is also what a batch row reports as its error, and INSUFFICIENT_BALANCE on conversions. detail is display prose:
201 with the payout failed, and payout.failed follows. An order your balance cannot cover is created awaiting_funds with funding instructions, and is refused with 422 and the same code only when settlement funding is not available, as the orders error reference lists.
Branch on code where a body has one, as the insufficient-balance refusal does. Otherwise branch on the code in message where a body carries one (on POST /v1/payouts, for example AMOUNT_OUT_OF_BOUNDS or CORRIDOR_NOT_ENABLED), and never on detail. A body with no code whose message is only a status phrase, such as Bad Request, carries no code.
Validation shape
Field validation failures are400 responses with a details array. See Recipient and sender field validation below for the full contract.
Basic Error Handler
One handler covers all three shapes. Read the top-level fields and fall back for legacy bodies:code member: where a legacy money-movement rejection has a stable code, it is the message, which the handler above lifts into code. The insufficient-balance refusal carries code itself, which the handler reads first. Treat any other body without a code as unknown and fall back to the HTTP status.
Error Code Registry
code values are stable and never change meaning. Each maps to a failure category that fixes the HTTP status and retry semantics:
INSUFFICIENT_FUNDS is registered but no endpoint returns it: payouts and conversions refuse an insufficient balance with the legacy 400 whose code is INSUFFICIENT_BALANCE, and orders refuse with a 422 whose message is the same code when settlement funding is not available.
The “Retryable” column shows the registry default. The envelope’s
retryable
flag is authoritative for each response: on POST requests it is forced to
false unless the request carries an Idempotency-Key, so a retry can never
double a payout. Always branch on the flag, not this table.detail string plus category and retryable. There is no structured details object with balances, currency lists, or route lists. Branch on code and retryable:
Validation Errors
Recipient and sender field validation
Some recipient requirements are validated synchronously when you submit a payout (POST /v1/payouts) or create a beneficiary (POST /v1/beneficiaries). For example, an individual recipient paid to certain corridors must have both a first and last name, and account identifiers (IBAN, account number, routing number) must match the format the destination expects.
When a recipient or sender fails one of these checks the API responds with 400 and a details array naming every field that check found. The checks run in sequence (corridor-required recipient fields, then recipient structure, then sender fields), so a request that fails more than one is corrected one check per round-trip. Recipient structural rules and sender rules return one entry per field, naming the field, a stable machine code, and a human-readable message. Recipient field paths are relative to the recipient object (address, name, account.iban); sender paths are given in full (senderInfo.gender). Bank account format rules (bankCode, crAccount) are checked on the account object as a whole, so they return one entry for that object (field such as beneficiary.accounts.0.data) whose message names each failing field, with no code. In every case the top-level message is the entries’ messages joined with ; , so a client that reads only message still sees everything to fix:
code rather than the message text. Common codes:
These checks run before the payout is queued, so an invalid recipient or sender is rejected immediately at submit time rather than failing later and being refunded. Fix the named fields and resubmit. The recipient codes apply to
POST /v1/payouts and POST /v1/beneficiaries; the two SENDER_INFO_* codes apply to POST /v1/payouts only. The batch form of POST /v1/payouts (an array body) reports a recipient or sender rejection per row, as { error, clientRef } where error is that row’s folded message; the details array is not carried per row. A field-format rejection (for example a beneficiary.accounts.0.data bank-account rule) is checked on every row before any is created and rejects the whole array with one 400.
A crypto payout to an individual beneficiary that has no dateOfBirth is refused at submit time with a 400 whose message names the field and carries no code; add the date of birth with PUT /v1/beneficiaries/{id} and resubmit. The sandbox does not apply this check. See Travel Rule.
Client-Side Validation
Validate data before API calls to catch problems without a round trip:clientRefis a duplicate guard, not a replay mechanism. Submitting a second payout with the sameclientRefreturns400with the messagePayout with this client reference already exists. It does not return the original payout. After an ambiguous failure (timeout, dropped connection), look the payout up withGET /v1/payouts?clientRef=<ref>before resubmitting, or use anIdempotency-Keyto get a true replay.- There is no global amount cap. The API constraint is format only: a positive decimal string with up to 8 decimal places. Real limits are per rail (for example mobile money: XOF 200–2,000,000, XAF 500–1,000,000, GHS 5–25,000) and per client, surfaced as
LIMIT_EXCEEDED. See Payouts for corridor-specific caps. - Currencies. EUR, USD, GBP, USDC, EURC, and NGN are enabled by default; USDT, XOF, XAF, and GHS are enabled via your account manager. ZMW, MZN, MWK, and EGP are sandbox-only. See the currency-to-route matrix in Payouts.
- EUR and GBP payouts need no route. They deliver to
ibanaccounts over Zuba’s international transfer network; the rail is selected automatically and surfaces asinternationalon payout reads. See Payment Routes.
Retry Strategies
Idempotent retries with Idempotency-Key
POST /v1/payouts accepts an optional Idempotency-Key header: a client-generated key, scoped to your workspace. This is the replay-safe retry mechanism:
- Same key + same body: replays the original response without creating a second payout.
- Same key + different body: rejected with
422 IDEMPOTENCY_KEY_REUSED. This indicates a bug in your client; never retry it with the same key. - Same key while the first request is still in flight: returns a retryable
409 IDEMPOTENCY_KEY_PROCESSING. Retry the identical request to receive the stored response. - Keys expire 24 hours after the original request completes; a repeat after expiry is treated as a new request.
Idempotency-Key when creating payouts. Without one, the envelope’s retryable flag is forced to false on the response, and a blind retry of a create that actually committed hits the clientRef duplicate guard (400) instead of getting the payout back.
Exponential Backoff
Retry on network errors and on responses whose envelope saysretryable: true, reusing the same key on every attempt:
retryable flag over hand-rolled status heuristics: it already encodes the safety rules, including the retryable 409 IDEMPOTENCY_KEY_PROCESSING (a 4xx a naive “never retry 4xx” rule would wrongly treat as permanent). On legacy endpoints without the envelope, retry only 429, 502, 503, 504, and network errors. Never blind-retry a POST that lacks idempotency protection.
Status Monitoring
Webhooks first
Webhooks are the primary status mechanism: Zuba pushes payout, deposit, and order status changes to your endpoint as they happen. Use polling only as a fallback (for example, to reconcile after webhook downtime).Polling as a fallback
Each resource has its own terminal statuses:
Note that
completed is not a payout status: the success state for a payout is paid.
404 as permanent (the payout does not exist); transient errors while polling can simply wait for the next interval.
Error Recovery
There is no automatic route-fallback to build client-side. Recover per scenario:PAYOUT_PROVIDER_UNAVAILABLE(503, retryable): retry the same request later with the sameIdempotency-Key, or callGET /v1/payouts/available-rails?accountId=<uuid>to check which rails currently serve the beneficiary’s account and submit a new payout (with a newclientRef) on a served rail.- Ambiguous outcome (timeout or dropped connection on create): if you sent an
Idempotency-Key, resend the identical request; the response is replayed. Otherwise, checkGET /v1/payouts?clientRef=<ref>before resubmitting. failedpayout: held funds are returned to your balance automatically. Fix the underlying cause (usually recipient details), then submit a new payout with a newclientRef.INSUFFICIENT_BALANCE(400): nothing was debited. Fund the balance, then submit the payout again with a newclientRef. A fiat payout sent without a quote is also recorded asfailedunder its originalclientRefand emitspayout.failedfor it, so resubmit from one of the two signals, not both; resubmitting the original reference is refused as a duplicate. A payout funded from a managed deposit account whose bank declines to release the USD is not refused with this400: it returns201as afailedpayout, recovered as above.LIMIT_EXCEEDED: contact your account manager about your limits. Amounts above corridor caps are served by the orders channel.
Best Practices
- Send an
Idempotency-Keyon everyPOST /v1/payouts: it is the only replay-safe retry mechanism. - Branch on
code, never on message text:detailandmessagestrings can change; codes are stable. - Respect the
retryableflag: it encodes the retry-safety rules, including POST suppression without a key. - Subscribe to webhooks for status changes; poll only as a fallback.
- Log
request_idfrom every error response: it is the correlation id support uses to trace your request. - Rehearse failure paths in the sandbox with the documented deterministic failure values before going live.
Getting Help
When contacting support about a failed request, quote therequest_id from the problem envelope (also returned in the x-request-id response header). It lets support trace the exact request through Zuba’s systems.
Next Steps
- Implement webhook notifications for real-time status updates
- Test error scenarios end-to-end in the sandbox
- Review the Payouts concepts page for routes, currencies, and corridor limits