> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zuba.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Money amounts are always major-unit decimal strings (e.g. "1000.00"), never floats and never minor units, in every request, response, and webhook payload.
> Authentication is OAuth 2.0 client credentials: exchange the Client ID and Client Secret at the Token URL for a 24-hour JWT and cache it; do not request a token per call.
> Set a unique clientRef on every money-moving request; it is the idempotency key, and retries return the original resource.
> Quotes are single-use, intent-locked, and expire fast: always read expiresAt, consume the quote with the executor matching its intent, and re-quote on expiry instead of retrying.
> Prefer webhooks over polling for payout, order, application, and virtual-account status tracking.
> Use the sandbox (api.sandbox.zuba.com) with its deterministic magic values before touching production.

# Error Handling

> Handle errors and edge cases gracefully in your payment integration

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:

| Status | Meaning |
| - | - |
| `400` | Bad Request: malformed input, a missing required field, a field validation failure, or a payout or conversion rejection such as `INSUFFICIENT_BALANCE` (see [Legacy shape](#legacy-shape)). Validation failures include a `details` array (see [Validation Errors](#validation-errors)). |
| `401` | Unauthorized: invalid or missing credentials. |
| `403` | Forbidden: authenticated, but not permitted to perform this action. |
| `404` | Not Found: the resource does not exist. |
| `409` | Conflict: a business decline (limits, state conflict) or an idempotent request still in flight (retry it). |
| `422` | Unprocessable Entity: a decline on well-formed input, such as recipient details invalid for the rail, an `Idempotency-Key` reused with a different body, or a transaction-limit decline. |
| `429` | Too Many Requests: rate limit exceeded. |
| `500` | Internal Server Error: an unexpected error on Zuba's side. |
| `502` | Bad Gateway: a legacy upstream-failure status still emitted by some endpoints (e.g. deposit-address provisioning). |
| `503` | Service Unavailable: an upstream provider is unavailable. Retryable. |
| `504` | Gateway Timeout: an upstream dependency was too slow. Retryable. |

### 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](#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):

```json theme={"dark"}
{
  "type": "https://errors.zuba.com/business-decline/payout-not-cancellable",
  "title": "The payout can no longer be cancelled",
  "status": 409,
  "detail": "A payout in status 'processing' cannot be cancelled",
  "code": "PAYOUT_NOT_CANCELLABLE",
  "category": "BUSINESS_DECLINE",
  "request_id": "3f9d2b6e-1c47-4a58-9e0b-7a12c4d8f631",
  "retryable": false,
  "instance": "/v1/payouts/8b1d4a9e-2f3c-4d5e-9a0b-1c2d3e4f5a6b/cancel"
}
```

| Field | Description |
| - | - |
| `type` | Stable URI identifying the problem type. |
| `title` | Short, stable summary of the problem type. |
| `status` | HTTP status, repeated in the body. |
| `detail` | Human-readable explanation of this occurrence. Never branch on it. |
| `code` | Stable machine-readable code: this is what your client branches on. |
| `category` | One of six failure categories (see [the registry](#error-code-registry)) that fixes the retry class. |
| `request_id` | Correlation id for this request (also returned in the `x-request-id` response header). Quote it to support. |
| `retryable` | Whether you may safely retry the same request. On `POST` requests it is forced to `false` unless an `Idempotency-Key` protects the request. |
| `instance` | The request path this occurrence relates to. |

### Legacy shape

Endpoints not yet migrated return the legacy top-level shape:

```json theme={"dark"}
{
  "statusCode": 404,
  "message": "Payout not found",
  "error": "Not Found",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "path": "/v1/payouts/8b1d4a9e-2f3c-4d5e-9a0b-1c2d3e4f5a6b"
}
```

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:

```json theme={"dark"}
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Insufficient balance",
  "code": "INSUFFICIENT_BALANCE",
  "detail": "Insufficient balance to cover the debit, including fees.",
  "timestamp": "2026-01-15T10:30:00.000Z",
  "path": "/v1/payouts"
}
```

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](/guides/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](#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:

```typescript theme={"dark"}
// A legacy rejection carries its stable code in `code` or, for most codes, as `message`.
const LEGACY_CODE = /^[A-Z][A-Z0-9_]*$/;

class ZubaAPIError extends Error {
  constructor(status, body) {
    super(body.detail || body.message || `HTTP ${status} error`);
    this.name = 'ZubaAPIError';
    this.status = status;
    this.code = body.code ?? (LEGACY_CODE.test(body.message) ? body.message : undefined);
    this.category = body.category;
    this.retryable = body.retryable === true;
    this.requestId = body.request_id;
    this.details = body.details;              // validation errors (400)
  }
}

async function handleZubaResponse(response) {
  if (response.ok) {
    return await response.json();
  }
  const body = await response.json().catch(() => ({}));
  throw new ZubaAPIError(response.status, body);
}
```

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:

| Code | Category | HTTP status | Retryable |
| - | - | - | - |
| `INVALID_REQUEST` | `INVALID_REQUEST` | 400 | No |
| `MISSING_REQUIRED_FIELD` | `INVALID_REQUEST` | 400 | No |
| `UNAUTHENTICATED` | `AUTH` | 401 | No |
| `FORBIDDEN` | `AUTH` | 403 | No |
| `RESOURCE_NOT_FOUND` | `INVALID_REQUEST` | 404 | No |
| `PAYOUT_NOT_PERMITTED` | `BUSINESS_DECLINE` | 409 | No |
| `INSUFFICIENT_FUNDS` | `BUSINESS_DECLINE` | 409 | No |
| `LIMIT_EXCEEDED` | `BUSINESS_DECLINE` | 409 | No |
| `STATE_CONFLICT` | `BUSINESS_DECLINE` | 409 | No |
| `PAYOUT_NOT_CANCELLABLE` | `BUSINESS_DECLINE` | 409 | No |
| `RECIPIENT_DETAILS_INVALID` | `BUSINESS_DECLINE` | 422 | No |
| `IDEMPOTENCY_KEY_REUSED` | `INVALID_REQUEST` | 422 | No |
| `IDEMPOTENCY_KEY_PROCESSING` | `DEGRADED` | 409 | **Yes** |
| `UPSTREAM_PROVIDER_ERROR` | `UPSTREAM_PROVIDER` | 503 | Yes |
| `PAYOUT_PROVIDER_UNAVAILABLE` | `UPSTREAM_PROVIDER` | 503 | Yes |
| `UPSTREAM_TIMEOUT` | `DEGRADED` | 504 | Yes |
| `INTERNAL_ERROR` | `INTERNAL` | 500 | No |

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

<Note>
  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.
</Note>

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`:

```typescript theme={"dark"}
function handlePayoutError(error) {
  switch (error.code) {
    case 'INSUFFICIENT_BALANCE':
      // Fund the balance or reduce the amount, then send it as a new payout
      // with a new clientRef; do not resubmit unchanged.
      return { action: 'TOPUP_BALANCE', retry: false };

    case 'RECIPIENT_DETAILS_INVALID':
      // Fix the beneficiary's details, then submit a new payout.
      return { action: 'UPDATE_BENEFICIARY', retry: false };

    case 'LIMIT_EXCEEDED':
      // Contact your account manager about limits. Amounts above corridor
      // caps are served by the orders channel (see /guides/orders).
      return { action: 'CONTACT_ACCOUNT_MANAGER', retry: false };

    case 'IDEMPOTENCY_KEY_PROCESSING':
      // The first request with this key is still in flight:
      // retry the identical request to receive its response.
      return { action: 'RETRY_SAME_REQUEST', retry: true };

    default:
      return { action: error.retryable ? 'RETRY_LATER' : 'CONTACT_SUPPORT', retry: error.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:

```json theme={"dark"}
{
  "statusCode": 400,
  "error": "BAD_REQUEST",
  "message": "senderInfo.phoneNumber is required for XAF payouts to Cameroon (international format, e.g. +237650000000); senderInfo.gender is required for XAF payouts to Cameroon (M or F)",
  "details": [
    {
      "field": "senderInfo.phoneNumber",
      "code": "SENDER_INFO_INCOMPLETE",
      "message": "senderInfo.phoneNumber is required for XAF payouts to Cameroon (international format, e.g. +237650000000)"
    },
    {
      "field": "senderInfo.gender",
      "code": "SENDER_INFO_INCOMPLETE",
      "message": "senderInfo.gender is required for XAF payouts to Cameroon (M or F)"
    }
  ],
  "timestamp": "2026-01-15T10:30:00.000Z",
  "path": "/v1/payouts"
}
```

Branch on `code` rather than the message text. Common codes:

| Code | Meaning |
| - | - |
| `MISSING_BENEFICIARY_NAME` | An individual recipient's name does not split into a first and last name, or a business recipient is missing its company name. |
| `BENEFICIARY_INCOMPLETE` | A field the destination corridor requires (address, city, postcode, country subdivision, or account holder name) is missing. |
| `MISSING_BANK_ACCOUNT_DETAILS` | Account number and/or routing number is missing for a bank-account payout. |
| `INVALID_IBAN` | IBAN does not match the expected country-code and check-digit format. For a CHF payout, it is also returned when the Swiss IBAN has the wrong length, fails its check digits, or is a QR-IBAN (used only for QR-bill payments). |
| `IBAN_COUNTRY_NOT_SUPPORTED` | The IBAN's country is not one the payout currency is paid to; supply an IBAN from the currency's destination country. |
| `INVALID_ACCOUNT_NUMBER` / `INVALID_ROUTING_NUMBER` | Account or routing number is the wrong length or format for the corridor. |
| `IBAN_NOT_SUPPORTED_FOR_DESTINATION` | The destination accepts only a domestic bank account number for international transfers; supply that instead of an IBAN. |
| `UNSUPPORTED_CORRIDOR` | The currency is not supported for the chosen account type. |
| `SENDER_INFO_INCOMPLETE` | A required `senderInfo` field is missing: one the sender type always needs (a name for an individual; company name, registration number and country for a business) or one the destination corridor adds (the sender's phone, fund origin, gender and country on an XAF payout). |
| `SENDER_INFO_INVALID` | A `senderInfo` field is present that the sender type does not admit (for example a `dateOfBirth` on a business sender). |
| `ORIGINATOR_NOT_ON_RECORD` | The payout names no `senderInfo` and the paying account (your workspace, or the sub-account named in `Zuba-Account-Id`) holds no approved KYB application to take the originator from, or that application is missing what the originator needs: its name, a sub-account's registration number, or, for a crypto payout from an account approved as an individual, the applicant's date of birth. The `message` says which. The `field` is `senderInfo`. Supplying `senderInfo` names a third party; to pay as the account itself, contact support to have its KYB application completed and approved, or the missing detail recorded on the approved application. |
| `NARRATION_TOO_LONG` | `description` is longer than the payout currency allows, counted after accented and special characters are converted for the payment rail (a crypto payout counts the text as entered). The `field` is `description` and the `message` names the limit; `GET /v1/payouts/requirements` reports it as `payout.descriptionMaxLength`. In a batch it is a field-format rejection, so it rejects the whole array. |

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 and `ORIGINATOR_NOT_ON_RECORD` 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](/concepts/payouts#travel-rule).

### Client-Side Validation

Validate data before API calls to catch problems without a round trip:

```typescript theme={"dark"}
const PRODUCTION_CURRENCIES = ['EUR', 'USD', 'GBP', 'USDC', 'USDT', 'NGN', 'XOF', 'XAF', 'GHS', 'KES', 'UGX'];
const ROUTES = ['bank_transfer', 'ach', 'fedwire', 'swift', 'crypto', 'mobile_money'];

function validatePayout(payout) {
  const errors = [];

  // clientRef: required, unique per payout, max 100 characters
  if (!payout.clientRef || payout.clientRef.length > 100) {
    errors.push('clientRef is required (max 100 characters, unique per payout)');
  }

  // amount: positive decimal string with up to 8 decimal places
  if (!/^\d+(\.\d{1,8})?$/.test(payout.amount ?? '') || Number(payout.amount) <= 0) {
    errors.push('amount must be a positive decimal string with up to 8 decimal places');
  }

  if (!PRODUCTION_CURRENCIES.includes(payout.currency)) {
    errors.push(`currency must be one of: ${PRODUCTION_CURRENCIES.join(', ')}`);
  }

  // Reference an existing beneficiary by id
  if (!payout.beneficiary?.id) {
    errors.push('beneficiary.id is required');
  }

  // route is optional: when set, it must be a live route
  if (payout.route && !ROUTES.includes(payout.route)) {
    errors.push(`route must be one of: ${ROUTES.join(', ')}`);
  }

  return errors;
}
```

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`](#idempotent-retries-with-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–50,000) and per client, surfaced as `LIMIT_EXCEEDED`. See [Payouts](/concepts/payouts) for corridor-specific caps.
* **Currencies.** EUR, USD, GBP, USDC, 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](/concepts/payouts#payment-routes).
* **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](/concepts/payouts#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:

```typescript theme={"dark"}
async function createPayoutWithRetry(payout, { maxAttempts = 4, baseDelayMs = 1000 } = {}) {
  const idempotencyKey = crypto.randomUUID();

  for (let attempt = 1; ; attempt++) {
    try {
      const response = await fetch('https://api.zuba.com/v1/payouts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer YOUR_API_KEY',
          'Idempotency-Key': idempotencyKey
        },
        body: JSON.stringify(payout)
      });
      return await handleZubaResponse(response);
    } catch (error) {
      // Branch on the envelope's retryable flag, not on status-code heuristics.
      // Network errors are safe to retry because the key deduplicates them.
      const retryable = error instanceof ZubaAPIError ? error.retryable : true;

      if (!retryable || attempt >= maxAttempts) {
        throw error;
      }

      const delay = baseDelayMs * 2 ** (attempt - 1) * (1 + Math.random() * 0.1);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}
```

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](/guides/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:

| Resource | Endpoint | Terminal statuses |
| - | - | - |
| Payouts | `GET /v1/payouts/{id}` | `paid`, `failed`, `cancelled` (in flight: `created`, `queued`, `processing`) |
| Deposits | `GET /v1/deposits/{id}` | `completed`, `failed`, `cancelled`, `refunded` |
| Orders | `GET /v1/orders/{id}` | `completed`, `failed`, `expired`, `cancelled` |

Note that `completed` is **not** a payout status: the success state for a payout is `paid`.

```typescript theme={"dark"}
const PAYOUT_TERMINAL_STATUSES = ['paid', 'failed', 'cancelled'];

async function waitForPayout(payoutId, { intervalMs = 5000, timeoutMs = 300000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  let lastStatus = 'unknown';

  while (Date.now() < deadline) {
    const response = await fetch(`https://api.zuba.com/v1/payouts/${payoutId}`, {
      headers: { 'Authorization': 'Bearer YOUR_API_KEY' }
    });
    const payout = await handleZubaResponse(response);
    lastStatus = payout.status;

    if (PAYOUT_TERMINAL_STATUSES.includes(payout.status)) {
      return payout;
    }

    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }

  throw new Error(`Payout ${payoutId} still in flight after ${timeoutMs}ms (last status: ${lastStatus})`);
}
```

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](/guides/orders).

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](/guides/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](/guides/sandbox-testing)** 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

* Implement [webhook notifications](/guides/webhooks) for real-time status updates
* Test error scenarios end-to-end in the [sandbox](/guides/sandbox-testing)
* Review the [Payouts concepts page](/concepts/payouts) for routes, currencies, and corridor limits


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.