Skip to main content
Robust error handling is essential in a payment integration. This guide documents Zuba’s error response formats, the stable error-code registry, safe retry semantics with idempotency keys, and recovery patterns for payouts, deposits, and orders.

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 a 400 with a details array 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 with Content-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:
Many money-movement rejections in the legacy shape carry a stable code as 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:
A payout funded from USD in a managed deposit account can also fail for funds after it is created: when the bank holding the account declines to release the USD, the request returns 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 are 400 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:
Most legacy bodies carry no 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.
The envelope carries a human-readable 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:
Branch on 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:
A few semantics worth encoding in your client:
  • clientRef is a duplicate guard, not a replay mechanism. Submitting a second payout with the same clientRef returns 400 with the message Payout with this client reference already exists. It does not return the original payout. After an ambiguous failure (timeout, dropped connection), look the payout up with GET /v1/payouts?clientRef=<ref> before resubmitting, or use an Idempotency-Key to 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 iban accounts over Zuba’s international transfer network; the rail is selected automatically and surfaces as international on 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.
Always send an 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 says retryable: true, reusing the same key on every attempt:
Prefer the envelope’s 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.
Treat a 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 same Idempotency-Key, or call GET /v1/payouts/available-rails?accountId=<uuid> to check which rails currently serve the beneficiary’s account and submit a new payout (with a new clientRef) 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, check GET /v1/payouts?clientRef=<ref> before resubmitting.
  • failed payout: held funds are returned to your balance automatically. Fix the underlying cause (usually recipient details), then submit a new payout with a new clientRef.
  • INSUFFICIENT_BALANCE (400): nothing was debited. Fund the balance, then submit the payout again with a new clientRef. A fiat payout sent without a quote is also recorded as failed under its original clientRef and emits payout.failed for 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 this 400: it returns 201 as a failed payout, recovered as above.
  • LIMIT_EXCEEDED: contact your account manager about your limits. Amounts above corridor caps are served by the orders channel.
Never split a payout into smaller chunks to get under a limit.

Best Practices

  1. Send an Idempotency-Key on every POST /v1/payouts: it is the only replay-safe retry mechanism.
  2. Branch on code, never on message text: detail and message strings can change; codes are stable.
  3. Respect the retryable flag: it encodes the retry-safety rules, including POST suppression without a key.
  4. Subscribe to webhooks for status changes; poll only as a fallback.
  5. Log request_id from every error response: it is the correlation id support uses to trace your request.
  6. 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 the request_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