# Zuba > ## 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. # Create account Source: https://docs.zuba.com/api-reference/accounts/create-account /openapi.json post /v1/accounts Creates a `pending` sub-account (a master’s end-customer) under the caller’s master. The parent is resolved from the API key, never from the body. Pass `Idempotency-Key`; replaying the same key returns the original 201 instead of creating a duplicate. Emits the `account.created` webhook. # Get account Source: https://docs.zuba.com/api-reference/accounts/get-account /openapi.json get /v1/accounts/{accountId} Returns a single account record owned by the caller, addressed by its id. # List accounts Source: https://docs.zuba.com/api-reference/accounts/list-accounts /openapi.json get /v1/accounts Returns the accounts in the caller’s tree, newest-first, with opaque cursor pagination. Filter by `parent` (an account id, or `me` for the caller’s direct children) and `status`. # Update account Source: https://docs.zuba.com/api-reference/accounts/update-account /openapi.json patch /v1/accounts/{accountId} Suspend, close, or re-activate an account by transitioning its status. Allowed: active→suspended/closed, suspended→active (re-activate) or closed, pending→closed. The KYB-gated pending→active step is not settable here (activation happens after KYB approval); closed is terminal. A PATCH to the current status is a no-op that returns the record; an invalid transition returns 409. # Create a new API key Source: https://docs.zuba.com/api-reference/api-keys/create-a-new-api-key /openapi.json post /v1/api-keys Provisions a new M2M application in Auth0 and returns credentials. The client secret is only shown once. # Delete an API key Source: https://docs.zuba.com/api-reference/api-keys/delete-an-api-key /openapi.json delete /v1/api-keys/{clientId} Permanently delete an API key from Auth0 and mark as inactive in database. # Get API key details Source: https://docs.zuba.com/api-reference/api-keys/get-api-key-details /openapi.json get /v1/api-keys/{clientId} Get details of a specific API key (client secret will be hidden) # List all API keys Source: https://docs.zuba.com/api-reference/api-keys/list-all-api-keys /openapi.json get /v1/api-keys Deprecated: use GET /v2/api-keys for bounded opaque cursor pagination. # Rotate client secret Source: https://docs.zuba.com/api-reference/api-keys/rotate-client-secret /openapi.json post /v1/api-keys/{clientId}/rotate-secret Generate a new client secret for an API key. The old secret will be invalidated immediately. # Add a person to an application Source: https://docs.zuba.com/api-reference/applications-v2/add-a-person-to-an-application /openapi.json post /v2/applications/{applicationId}/persons # Confirm a document upload completed Source: https://docs.zuba.com/api-reference/applications-v2/confirm-a-document-upload-completed /openapi.json post /v2/applications/{applicationId}/documents/{docId}/confirm # Create a KYB/KYC application Source: https://docs.zuba.com/api-reference/applications-v2/create-a-kybkyc-application /openapi.json post /v2/applications Supports both `business` (KYB) and `individual` (KYC) applications, in any corridor. Identity is verified from an uploaded government-ID document (`photo_id`): a business submits each director’s / applicant’s photo_id, an individual submits the applicant’s photo_id — uploaded via the /documents endpoints before /submit. # Get an application by id Source: https://docs.zuba.com/api-reference/applications-v2/get-an-application-by-id /openapi.json get /v2/applications/{id} # List applications Source: https://docs.zuba.com/api-reference/applications-v2/list-applications /openapi.json get /v2/applications With Zuba-Account-Id: the named sub-account’s application. Without it: every application the caller owns (self + each owned sub-account). # List persons on an application Source: https://docs.zuba.com/api-reference/applications-v2/list-persons-on-an-application /openapi.json get /v2/applications/{applicationId}/persons # Remove a person (also soft-deletes that person’s documents) Source: https://docs.zuba.com/api-reference/applications-v2/remove-a-person-also-soft-deletes-that-person’s-documents /openapi.json delete /v2/applications/{applicationId}/persons/{personId} # Request a pre-signed upload URL for a document Source: https://docs.zuba.com/api-reference/applications-v2/request-a-pre-signed-upload-url-for-a-document /openapi.json post /v2/applications/{applicationId}/documents/upload-url # Soft-delete a document Source: https://docs.zuba.com/api-reference/applications-v2/soft-delete-a-document /openapi.json delete /v2/applications/{applicationId}/documents/{docId} # Submit (or resubmit) an application for review Source: https://docs.zuba.com/api-reference/applications-v2/submit-or-resubmit-an-application-for-review /openapi.json post /v2/applications/{id}/submit The single explicit state transition: draft → under_review, or resubmit of a more_info_requested application. A workspace invited with additional information to answer and disclosures to sign submits its answers with its own application, and both go under review together. # Supply provider-required attributes for a person Source: https://docs.zuba.com/api-reference/applications-v2/supply-provider-required-attributes-for-a-person /openapi.json post /v2/applications/{applicationId}/persons/{personId}/enrich Fills attributes a payment provider’s KYC requires that Zuba’s review does not assess, so they can be supplied after an application is approved. Accepted in any application status, and only into a field the person has no value for — correcting an existing value goes through PATCH while the application is still editable. # Supply provider-required attributes for an application Source: https://docs.zuba.com/api-reference/applications-v2/supply-provider-required-attributes-for-an-application /openapi.json post /v2/applications/{id}/enrich Fills attributes a payment provider’s KYC requires that Zuba’s review does not assess: the applicant’s own attributes when the applicant is the beneficial owner, the business industry code, and the market the business primarily serves. Accepted in any application status, and only into a field the application has no value for; correcting an existing value goes through PATCH while the application is still editable. # Update a person Source: https://docs.zuba.com/api-reference/applications-v2/update-a-person /openapi.json patch /v2/applications/{applicationId}/persons/{personId} # Update an application (draft data only; never changes status) Source: https://docs.zuba.com/api-reference/applications-v2/update-an-application-draft-data-only;-never-changes-status /openapi.json patch /v2/applications/{id} # List banks Source: https://docs.zuba.com/api-reference/banks/list-banks /openapi.json get /v1/banks Returns the supported banks for the given country as `{ name, bankCode }` pairs, sorted by name. Send a `bankCode` as `data.bankCode` on a `bank_account` beneficiary account whose `currency` is the country's. Every `bankCode` is a Zuba identifier (`ng_0001`, `gh_0003`, ...). For Nigeria, the default when `country` is omitted, each bank also carries `shortCode` (its short code in Zuba's registry: the 3-digit CBN code for most banks, a lettered or longer code for a few) and `nipCode` (its 6-digit NIP code), plus `alternativeCodes` where a bank has any; an NGN account accepts every one of those forms as `bankCode`, so a saved account keeps working whichever form it carries. The Nigerian list is the full registry, not the set of banks a rail has confirmed it can pay. # List banks (deprecated path) Source: https://docs.zuba.com/api-reference/banks/list-banks-deprecated-path /openapi.json get /v1/accounts/banks Deprecated alias of `GET /v1/banks`; call that path instead. # Convert corridors enabled for this client Source: https://docs.zuba.com/api-reference/conversions/convert-corridors-enabled-for-this-client /openapi.json get /v1/conversions/corridors # Execute a conversion (consume a quote, terminal state inline) Source: https://docs.zuba.com/api-reference/conversions/execute-a-conversion-consume-a-quote-terminal-state-inline /openapi.json post /v1/conversions # Get a conversion by id Source: https://docs.zuba.com/api-reference/conversions/get-a-conversion-by-id /openapi.json get /v1/conversions/{id} # List conversions (paginated) Source: https://docs.zuba.com/api-reference/conversions/list-conversions-paginated /openapi.json get /v1/conversions # API Reference Source: https://docs.zuba.com/api-reference/introduction Complete API reference for the Zuba Payment Platform ## Introduction The Zuba Payment Platform API is organized around REST principles: predictable, resource-oriented URLs; JSON-encoded requests and responses; and standard HTTP response codes, authentication, and verbs. ## Base URL **Production:** ``` https://api.zuba.com ``` **Sandbox:** ``` https://api.sandbox.zuba.com ``` ## Authentication The Zuba API uses the OAuth 2.0 Client Credentials flow for authentication. You can generate your credentials in the [Zuba Dashboard](https://sandbox.zuba.com) (Sandbox) or [Production Dashboard](https://dash.zuba.com). See our [Authentication Guide](/authentication) for detailed instructions on obtaining access tokens. Include your access token in the `Authorization` header: ```http theme={"dark"} Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6... ``` ## Request Format All POST requests should include the `Content-Type` header: ```http theme={"dark"} Content-Type: application/json ``` ## Response Format All responses are returned in JSON format: ### Example Success Response The example below is the response body of `GET /v1/ledger/balances`. Monetary values are always decimal **strings**, never JSON numbers: ```json theme={"dark"} { "userId": "user123", "clientId": "d7c75cf2-322f-4d27-b5fc-3418d1917309", "balances": [ { "currency": "EUR", "balance": "87.57104914", "pendingIn": "0.00", "pendingOut": "0.00", "totalBalance": "87.57104914", "isActive": true, "lastTransactionAt": "2026-01-12T12:50:48.792Z" } ], "totalValueEur": "87.57", "primaryCurrency": "EUR", "calculatedAt": "2026-01-15T12:11:24.559Z" } ``` ### Error Response Most errors use a JSON envelope with `statusCode`, `message`, and `error` fields. Validation errors also carry a `details` array identifying the offending fields: ```json theme={"dark"} { "statusCode": 400, "message": "bankCode '123123333' is not a recognized Nigerian bank code", "error": "BAD_REQUEST", "timestamp": "2026-01-15T12:12:24.062Z", "path": "/v1/beneficiaries", "details": [ { "field": "accounts.0.data", "message": "bankCode '123123333' is not a recognized Nigerian bank code" } ] } ``` Some errors, including `Idempotency-Key` errors and all unexpected `500`s, are returned as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem details instead, with `Content-Type: application/problem+json` and a different body shape: ```json theme={"dark"} { "type": "https://errors.zuba.com/invalid-request/idempotency-key-reused", "title": "The Idempotency-Key was reused with different parameters", "status": 422, "detail": "The Idempotency-Key was reused with different parameters", "code": "IDEMPOTENCY_KEY_REUSED", "category": "INVALID_REQUEST", "request_id": "0f6a2f3e-9d0b-4c66-8b7a-2f4f4bfa61a2", "retryable": false, "instance": "/v1/payouts" } ``` Check the response `Content-Type` header and handle both shapes: a parser keyed exclusively on `message` and `statusCode` will break on `application/problem+json` responses. On problem responses, branch on `type` or `code`, and use `retryable` to decide whether a retry can succeed. ## HTTP Status Codes The Zuba API uses conventional HTTP response codes: | Code | Description | | - | - | | `200` | Success - Request completed successfully | | `201` | Created - Resource created successfully | | `400` | Bad Request - Invalid request parameters or validation errors | | `401` | Unauthorized - Invalid or missing access token | | `403` | Forbidden - Insufficient permissions, or the requested capability is not enabled for your account. Contact support to enable additional payment methods or currencies. | | `404` | Not Found - Resource not found | | `409` | Conflict - A request with the same `Idempotency-Key` is still in flight; retry the identical request | | `422` | Unprocessable Entity - The request was understood but declined (e.g. invalid recipient details, `Idempotency-Key` reused with a different body) | | `429` | Too Many Requests - Rate limit exceeded; retry with backoff | | `500` | Internal Server Error - Something went wrong | ## Rate Limits Requests are rate limited per client IP address: | Window | Limit | | - | - | | 1 minute | 100 requests | Some endpoints carry their own limit, higher or lower than this one. Exceeding a limit returns HTTP `429 Too Many Requests` with a `Retry-After` header giving the seconds to wait. Back off and retry with exponentially increasing delays. ## Pagination List endpoints use cursor pagination. Results are ordered newest first. Pass the `nextCursor` returned by the previous page as `cursor` to fetch the next page. ### Request Parameters * `cursor` - Opaque cursor returned by the previous page * `limit` - Number of items per page (minimum 1, maximum 100, default 20) ### Example Response Format ```json theme={"dark"} { "object": "payout.list", "data": [ // Array of items ], "hasMore": true, "nextCursor": "eyJ2IjoxLCJjcmVhdGVkQXQiOiIyMDI2LTAxLTAxVDAwOjAwOjAwLjAwMFoiLCJpZCI6IjEyM2U0NTY3LWU4OWItNDJkMy1hNDU2LTQyNjYxNDE3NDAwMCJ9" } ``` When `hasMore` is true, pass `nextCursor` as `cursor` on the next request. When `hasMore` is false, `nextCursor` is `null`. ## Idempotency Two mechanisms prevent duplicate payouts: ### clientRef deduplication Every payout must carry a unique `clientRef`. A request that reuses an existing `clientRef` is rejected with HTTP `400`, so a resubmission can never create a duplicate payout. ### Idempotency-Key header `POST /v1/payouts` also accepts an optional `Idempotency-Key` header, a client-generated key scoped to your workspace: * **Same key, same body**: the original response is replayed; no second payout is created. * **Same key, different body**: rejected with HTTP `422` (`IDEMPOTENCY_KEY_REUSED`). * **Repeat while the first request is still in flight**: HTTP `409` (retryable); 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. ## Webhooks The Zuba API sends webhook notifications for events such as payout status changes. Every delivery is a JSON POST with this envelope: ```json theme={"dark"} { "id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "type": "payout.paid", "createdAt": "2026-03-23T14:30:00.000Z", "test": false, "data": { "id": "123e4567-e89b-12d3-a456-426614174000", "clientRef": "your-reference-123", "amount": "1000.00000000", "currency": "USD", "status": "paid", "completedAt": "2026-03-23T14:29:58.000Z" } } ``` Learn more in our [Webhooks Guide](/guides/webhooks). ## API Versioning The API version is specified in the URL path. All endpoints are served under `/v1`: ``` POST https://api.zuba.com/v1/payouts GET https://api.zuba.com/v1/payouts ``` > [!NOTE] > > FX and ledger endpoints were historically served at unversioned paths > (`/fx/...`, `/ledger/...`). Those legacy URLs continue to work as aliases of > the canonical `/v1/fx/...` and `/v1/ledger/...` paths, but are deprecated: > use the `/v1` form in new integrations. ## Testing Use Sandbox credentials for development: * **Sandbox Dashboard**: `https://sandbox.zuba.com` * **Sandbox Base URL**: `https://api.sandbox.zuba.com` * **Sandbox Token URL**: `https://zuba-test.us.auth0.com/oauth/token` The Sandbox provides: * Simulated payment processing * No real money movement * Access to test data and scenarios * Webhook testing capabilities ## Support Need help with integration? Contact our team: * **Email**: [support@zuba.com](mailto:support@zuba.com) * **Documentation**: Browse the complete API reference below ## Next Steps > **[Quickstart Guide](/quickstart)** > > Send your first payout in minutes > **[Payouts](/concepts/payouts)** > > Send money globally to beneficiaries > **[Ledger System](/concepts/ledger)** > > Double-entry accounting and transaction tracking > **[Webhooks](/guides/webhooks)** > > Receive real-time event notifications # Generate account statement for a specific currency Source: https://docs.zuba.com/api-reference/ledger/generate-account-statement-for-a-specific-currency /openapi.json get /v1/ledger/statement/{currency} Generate a detailed account statement for a currency account between two dates. The statement includes opening balance, all transactions (debits and credits), and closing balance for the specified period. Useful for reconciliation and accounting purposes. # Get all accounts for current user (admin view) Source: https://docs.zuba.com/api-reference/ledger/get-all-accounts-for-current-user-admin-view /openapi.json get /v1/ledger/accounts Retrieve a comprehensive list of all ledger accounts for the authenticated user. This admin-level view includes detailed account information for all currencies. Optionally include inactive accounts. # Get closing balances across all currencies for a past date Source: https://docs.zuba.com/api-reference/ledger/get-closing-balances-across-all-currencies-for-a-past-date /openapi.json get /v1/ledger/closing-balances Returns the end-of-day closing balance for each of your accounts as of the given UTC calendar date. The date must be in the past (yesterday or earlier); today and future dates are rejected because today is not yet closed. Backed by daily balance snapshots with transaction-replay fallback for dates without a snapshot. # Get current user balance for a specific currency Source: https://docs.zuba.com/api-reference/ledger/get-current-user-balance-for-a-specific-currency /openapi.json get /v1/ledger/balances/{currency} Retrieve balance details for a specific currency account. Currency code is case-insensitive (e.g., USD, usd, Usd all work). # Get current user balances across all currencies Source: https://docs.zuba.com/api-reference/ledger/get-current-user-balances-across-all-currencies /openapi.json get /v1/ledger/balances Retrieve your account balances in all currencies. Shows available balance (funds you can use), pending incoming/outgoing amounts, and total balance for each currency account. # Get transaction history for current user Source: https://docs.zuba.com/api-reference/ledger/get-transaction-history-for-current-user /openapi.json get /v1/ledger/transactions Deprecated: use GET /v2/ledger/transactions for stable opaque cursor pagination. # Cancel an order Source: https://docs.zuba.com/api-reference/orders/cancel-an-order /openapi.json post /v1/orders/{id}/cancel Cancels an order only before funding has been received. Once funded, the order is committed to the desk workflow and cannot be cancelled. Retrying a successful cancel returns the cancelled order. # Create an order from an order-intent quote; idempotent Source: https://docs.zuba.com/api-reference/orders/create-an-order-from-an-order-intent-quote;-idempotent /openapi.json post /v1/orders Executes a held order quote: debits the source amount from your balance into a hold and queues the order for desk execution. The quote is single-use; retrying with the same `quotationId` returns the original order. Orders are executed manually by the trade desk within the execution window returned on the order. When the `Zuba-Account-Id` header names one of your sub-accounts, the order is created for that sub-account and funds from its balance. # Get an order by id Source: https://docs.zuba.com/api-reference/orders/get-an-order-by-id /openapi.json get /v1/orders/{id} # List orders Source: https://docs.zuba.com/api-reference/orders/list-orders /openapi.json get /v1/orders Returns your orders newest-first with opaque cursor pagination. Filter by `status` and a `from`/`to` creation-time window. # Request an upload URL for a settlement proof of payment Source: https://docs.zuba.com/api-reference/orders/request-an-upload-url-for-a-settlement-proof-of-payment /openapi.json post /v1/orders/{id}/settlement/proof-upload-url Returns a short-lived URL to upload the payment confirmation for an order whose settlement has requirements.proofOfPayment=true, plus the key to pass back when submitting the proof. PUT the document (PDF, JPG or PNG) to the URL, then call the submit endpoint. # Submit an uploaded proof of payment for a settlement Source: https://docs.zuba.com/api-reference/orders/submit-an-uploaded-proof-of-payment-for-a-settlement /openapi.json post /v1/orders/{id}/settlement/proof Attaches the uploaded payment confirmation to the order settlement when requirements.proofOfPayment=true and starts funding confirmation. Once the transfer is confirmed the order moves to funds_received and an order.funds_received webhook is sent. Re-submitting after confirmation has started is a no-op. # Create deposit Source: https://docs.zuba.com/api-reference/pay-ins/create-deposit /openapi.json post /v1/deposits Create a new deposit. This endpoint supports various payment methods, including generating bank account details (IBAN and BIC) for manual bank transfers, or initiating other provider-specific payin flows based on the provided currency and user details. For NGN currency, additional user details (firstName, lastName, email, phoneNumber) are required to generate dynamic virtual accounts. # Get deposit status Source: https://docs.zuba.com/api-reference/pay-ins/get-deposit-status /openapi.json get /v1/deposits/{id} Check the status of any deposit by ID, regardless of the payment method. Returns current status, bank account details, and transaction information. For NGN payins, includes the virtual account details in metadata. # List deposits Source: https://docs.zuba.com/api-reference/pay-ins/list-deposits /openapi.json get /v1/deposits Deprecated: use GET /v2/deposits for opaque cursor pagination. # Cancel a payout Source: https://docs.zuba.com/api-reference/payouts/cancel-a-payout /openapi.json post /v1/payouts/{id}/cancel Cancels a payout while it is still `created` or `queued` and returns the balance its creation debited (principal, fee and any conversion legs) to the account it was taken from. A `payout.cancelled` webhook follows. Retrying a cancel is always safe: a cancelled payout is returned again, and a return that had not completed is driven to completion. # Create a new beneficiary Source: https://docs.zuba.com/api-reference/payouts/create-a-new-beneficiary /openapi.json post /v1/beneficiaries Create a new beneficiary with their account details for payouts. Idempotent: if a submitted account matches an active account already saved in the same scope (the sub-account named by Zuba-Account-Id, or the master account when none is named) on every identity it carries (its IBAN, and its bank code with account number), the existing beneficiary is returned unchanged instead of creating a duplicate. # Create one or multiple payouts Source: https://docs.zuba.com/api-reference/payouts/create-one-or-multiple-payouts /openapi.json post /v1/payouts Create a single payout or batch of payouts. Send a single payout object or an array of payout objects. The source currency can be specified via inputCurrency field, or will be automatically selected from the currency with the largest available balance. When the beneficiary is supplied inline (no `beneficiary.id`), the beneficiary is resolved idempotently: if a submitted account matches an active account already saved in the same scope (the sub-account named by Zuba-Account-Id, or the master account when none is named) on every identity it carries (its IBAN, and its bank code with account number), that account's beneficiary is reused and no duplicate row is created. # Get beneficiary by ID Source: https://docs.zuba.com/api-reference/payouts/get-beneficiary-by-id /openapi.json get /v1/beneficiaries/{id} Retrieve a specific beneficiary and their account details # Get payout by ID Source: https://docs.zuba.com/api-reference/payouts/get-payout-by-id /openapi.json get /v1/payouts/{id} Retrieve details of a specific payout transaction # Get the field requirements for a payout corridor Source: https://docs.zuba.com/api-reference/payouts/get-the-field-requirements-for-a-payout-corridor /openapi.json get /v1/payouts/requirements Returns what a payout into a corridor requires of both parties: the beneficiary fields and the `senderInfo` keys that must and may be provided, reported for an individual and for a business of each party so a caller never has to declare one type to learn the other. Pass `route` (and, for mobile money or SWIFT, `country`) to narrow the answer to one corridor; without them it is combined across every rail serving the currency, so satisfying it satisfies the most demanding one. For the recipient it also reports the rules a field value must satisfy, such as an individual name that must split into a first and a last name, as a pattern to validate against before submitting. Each rule is enforced at submit with a 400 naming the field and carrying a stable code; without `route`, a combined rule is enforced only where the provider that pays out applies it. It also reports, under `payout`, the longest `description` the currency carries. # List all beneficiaries Source: https://docs.zuba.com/api-reference/payouts/list-all-beneficiaries /openapi.json get /v1/beneficiaries Deprecated: use GET /v2/beneficiaries for bounded opaque cursor pagination. # List available payout rails for an account Source: https://docs.zuba.com/api-reference/payouts/list-available-payout-rails-for-an-account /openapi.json get /v1/payouts/available-rails Returns rail options with static metadata (label, fee-from, ETA) for the given account. For non-USD accounts returns empty array (frontend skips picker). # List payouts Source: https://docs.zuba.com/api-reference/payouts/list-payouts /openapi.json get /v1/payouts Deprecated: use GET /v2/payouts for opaque cursor pagination. # Request a presigned upload URL for a SWIFT purpose-of-payment document Source: https://docs.zuba.com/api-reference/payouts/request-a-presigned-upload-url-for-a-swift-purpose-of-payment-document /openapi.json post /v1/payouts/pop-documents/upload-url # Update beneficiary Source: https://docs.zuba.com/api-reference/payouts/update-beneficiary /openapi.json put /v1/beneficiaries/{id} Update an existing beneficiary's information # Create a quote Source: https://docs.zuba.com/api-reference/quotes/create-a-quote /openapi.json post /v1/quotes Mints a single-use held quote for the requested currency pair. `intent` selects the action: 'payout' prices a held cross-rate / fiat-source / same-currency quote (the corridor picks the engine), 'convert' prices an in-wallet conversion between the caller's own balances, and 'order' prices a trade-desk order (your markup + order-channel spread, no fee). Exactly one of `fromAmount` / `toAmount` fixes a leg of the pair: payout fixes `toAmount` (the beneficiary amount), convert fixes `fromAmount` (the source debit), and order fixes either side. The quote prices FX + markup, plus the per-conversion fee on convert intent, and locks the rate until it is consumed by the matching executor or expires. Read `expiresAt` for how long the quote is valid rather than assuming a duration. A payout's per-transaction fee (a flat amount, or basis points of the debit floored at that amount) is applied at payout creation, not here. Every intent returns the same QuoteResponseDto, always carrying a `rate` (1 for a same-currency or stablecoin-par quote). # Get a quote by ID Source: https://docs.zuba.com/api-reference/quotes/get-a-quote-by-id /openapi.json get /v1/quotes/{id} # Create (or return) my standing virtual account Source: https://docs.zuba.com/api-reference/virtual-accounts/create-or-return-my-standing-virtual-account /openapi.json post /v1/virtual-accounts Issues one durable collection account for the requested currency, owned by your master account — or by the sub-account named in `Zuba-Account-Id`, whose deposits then settle into that sub-account's balance. Idempotent per owner and currency: if a live account already exists it is returned with `201 → 200`. Some accounts are issued asynchronously and return `status: pending`; subscribe to the `virtual_account.active` / `virtual_account.failed` webhook events for the outcome. On `virtual_account.active`, fetch the issued details with `GET /v1/virtual-accounts` (polling that endpoint works too, but the webhook is the intended completion signal); a refused account is absent from that list. `pending` is a normal state, not an error: activation is validated out of band, can take far longer than a request cycle, and no account number exists until it completes. Requires the owner's application to be approved. # List my virtual accounts Source: https://docs.zuba.com/api-reference/virtual-accounts/list-my-virtual-accounts /openapi.json get /v1/virtual-accounts Returns every active, enabled virtual account of the request owner: your master account's own accounts, or the sub-account's named by `Zuba-Account-Id`. # What this owner still needs before an account can be issued Source: https://docs.zuba.com/api-reference/virtual-accounts/what-this-owner-still-needs-before-an-account-can-be-issued /openapi.json get /v1/virtual-accounts/requirements Answers, without issuing anything, whether a virtual account could be created right now for the request owner in the given currency — and when it could not, exactly what is outstanding and which call supplies it. Collect the answers from your customer before requesting the account, rather than discovering them from a failed request. Branch on `ready`; read `code` on each requirement, not its prose. A null `resolution` means no API call sets that requirement: it is either a fact assessed at review, acceptance the owner owes for the account terms, or a state of the owner or the workspace. Its `description` says what clears it. The `disclosure.*` family covers the terms the account would be opened under. One code, `disclosure.acknowledgement`, covers the whole outstanding package; the documents themselves travel in `disclosures` on that requirement — slug, title, version, PDF link, render order and the exact wording each acceptance stands for — so a client renders them without a second call. `disclosures` is null on every other code. # Get a wallet Source: https://docs.zuba.com/api-reference/wallets/get-a-wallet /openapi.json get /v1/wallets/{id} One wallet of the request owner. A wallet that belongs to another owner, including a sub-account's when the header is omitted, is not found. # List my wallets Source: https://docs.zuba.com/api-reference/wallets/list-my-wallets /openapi.json get /v1/wallets Returns the wallets of the request owner, newest first, on the shared cursor contract: your master account's own wallets, or the sub-account's named by `Zuba-Account-Id`. Addresses the platform minted to fund trade-desk orders are not listed. # Open a wallet Source: https://docs.zuba.com/api-reference/wallets/open-a-wallet /openapi.json post /v1/wallets Opens an on-chain deposit wallet on the requested network for your master account, or for the sub-account named in `Zuba-Account-Id`, whose deposits then settle into that sub-account's balance. Idempotent per owner, network and `clientRef`: if the wallet already exists it is returned with `201 → 200`, provided at least one asset on that network is still enabled for you; when none is, opening is refused and the existing wallet is read through `GET /v1/wallets` instead. Send any of the wallet's `assets` to its `address` on that network; the deposit is credited once it confirms on-chain, provided it meets the per-transfer minimum. A smaller transfer, or a transfer on a network other than the wallet's own, is not credited, does not appear in `GET /v1/deposits`, and cannot be recovered. # Create a webhook endpoint Source: https://docs.zuba.com/api-reference/webhooks/create-a-webhook-endpoint /openapi.json post /v1/webhooks # Delete a webhook endpoint Source: https://docs.zuba.com/api-reference/webhooks/delete-a-webhook-endpoint /openapi.json delete /v1/webhooks/{id} # Get a single webhook delivery (includes payload) Source: https://docs.zuba.com/api-reference/webhooks/get-a-single-webhook-delivery-includes-payload /openapi.json get /v1/webhooks/{id}/deliveries/{deliveryId} # Get a webhook endpoint Source: https://docs.zuba.com/api-reference/webhooks/get-a-webhook-endpoint /openapi.json get /v1/webhooks/{id} # List all webhook endpoints Source: https://docs.zuba.com/api-reference/webhooks/list-all-webhook-endpoints /openapi.json get /v1/webhooks Deprecated: use GET /v2/webhooks for bounded opaque cursor pagination. # List delivery attempts for a webhook endpoint Source: https://docs.zuba.com/api-reference/webhooks/list-delivery-attempts-for-a-webhook-endpoint /openapi.json get /v1/webhooks/{id}/deliveries Deprecated: use GET /v2/webhooks/:id/deliveries for opaque cursor pagination. # Re-send a failed webhook delivery Source: https://docs.zuba.com/api-reference/webhooks/re-send-a-failed-webhook-delivery /openapi.json post /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver Sends the stored payload of a failed delivery to the endpoint again: the same event id and, on payout events, the same sequence, under a fresh signature and timestamp. The delivery returns to pending and is retried on the normal schedule. Only a delivery whose status is failed can be re-sent. # Rotate the signing secret for a webhook endpoint Source: https://docs.zuba.com/api-reference/webhooks/rotate-the-signing-secret-for-a-webhook-endpoint /openapi.json post /v1/webhooks/{id}/rotate-secret # Send a test webhook event to this endpoint Source: https://docs.zuba.com/api-reference/webhooks/send-a-test-webhook-event-to-this-endpoint /openapi.json post /v1/webhooks/{id}/test # Update a webhook endpoint Source: https://docs.zuba.com/api-reference/webhooks/update-a-webhook-endpoint /openapi.json put /v1/webhooks/{id} # Authentication Source: https://docs.zuba.com/authentication Learn how to authenticate with the Zuba API using OAuth 2.0 ## Overview The Zuba API uses **OAuth 2.0 Client Credentials flow** for secure M2M (machine-to-machine) authentication. You'll exchange your Client ID and Client Secret for a short-lived JWT access token, which you'll use to authenticate API requests. ## Getting Your API Credentials ### Step 1: Generate Credentials 1. Log in to your [Zuba Dashboard](https://sandbox.zuba.com) (see the [Environments](#environments) table for the Sandbox and production dashboards) 2. Navigate to **Developers → API Keys** and generate a new key 3. **Important:** Copy and save your credentials immediately - you won't be able to see the Client Secret again! You'll receive the following credentials: * **Client ID**: Your application's public identifier * **Client Secret**: Your application's secret key. It starts with `zk_live_` for production keys and `zk_test_` for Sandbox keys, so you can tell at a glance which environment a key belongs to. Keys issued before this prefix existed have no prefix and keep working unchanged. * **Auth0 domain**: Append `/oauth/token` to get the token endpoint (e.g., `https://zuba-test.us.auth0.com/oauth/token` in Sandbox) * **Audience**: The environment-specific API identifier, returned when you create the key (e.g., `https://api.sandbox.zuba.com` in Sandbox) > [!WARNING] > > **Keep your Client Secret secure!** Never share it publicly or commit it to version control. Treat it like a password. If compromised, immediately rotate your credentials in the dashboard. ## Authentication Flow ### Step 2: Request an Access Token Exchange your Client ID and Client Secret for a JWT access token. The samples below use the Sandbox token URL and audience; for production, use `https://auth.zuba.com/oauth/token` with audience `https://api.zuba.com` (see [Environments](#environments)): ```bash curl theme={"dark"} curl -X POST "https://zuba-test.us.auth0.com/oauth/token" \ -H "Content-Type: application/json" \ -d '{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "audience": "https://api.sandbox.zuba.com", "grant_type": "client_credentials" }' ``` ```javascript JavaScript theme={"dark"} const getAccessToken = async () => { const response = await fetch('https://zuba-test.us.auth0.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: process.env.ZUBA_CLIENT_ID, client_secret: process.env.ZUBA_CLIENT_SECRET, audience: 'https://api.sandbox.zuba.com', grant_type: 'client_credentials' }) }); const data = await response.json(); return data.access_token; }; ``` ```python Python theme={"dark"} import requests import os def get_access_token(): response = requests.post( 'https://zuba-test.us.auth0.com/oauth/token', json={ 'client_id': os.environ['ZUBA_CLIENT_ID'], 'client_secret': os.environ['ZUBA_CLIENT_SECRET'], 'audience': 'https://api.sandbox.zuba.com', 'grant_type': 'client_credentials' } ) return response.json()['access_token'] ``` ```java Java theme={"dark"} import java.net.http.*; import java.net.URI; import com.google.gson.Gson; import com.google.gson.JsonObject; public class ZubaAuth { public static String getAccessToken() throws Exception { String clientId = System.getenv("ZUBA_CLIENT_ID"); String clientSecret = System.getenv("ZUBA_CLIENT_SECRET"); JsonObject requestBody = new JsonObject(); requestBody.addProperty("client_id", clientId); requestBody.addProperty("client_secret", clientSecret); requestBody.addProperty("audience", "https://api.sandbox.zuba.com"); requestBody.addProperty("grant_type", "client_credentials"); HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://zuba-test.us.auth0.com/oauth/token")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(new Gson().toJson(requestBody))) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); JsonObject jsonResponse = new Gson().fromJson(response.body(), JsonObject.class); return jsonResponse.get("access_token").getAsString(); } } ``` **Successful Response:** ```json theme={"dark"} { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...", "token_type": "Bearer", "expires_in": 86400 } ``` The `access_token` is a JWT that's valid for 24 hours (86400 seconds). You'll need to request a new token when it expires. ### Step 3: Use the Access Token Include the access token in the `Authorization` header of every API request: ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/payouts" \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6..." \ -H "Content-Type: application/json" ``` ```javascript JavaScript theme={"dark"} const accessToken = await getAccessToken(); const response = await fetch('https://api.sandbox.zuba.com/v1/payouts', { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); const data = await response.json(); ``` ```python Python theme={"dark"} access_token = get_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } response = requests.get( 'https://api.sandbox.zuba.com/v1/payouts', headers=headers ) ``` ```java Java theme={"dark"} String accessToken = ZubaAuth.getAccessToken(); HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/payouts")) .header("Authorization", "Bearer " + accessToken) .header("Content-Type", "application/json") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); String responseBody = response.body(); ``` ## Token Management ### Token Expiration Access tokens expire after 24 hours. Implement token caching and refresh logic to avoid requesting a new token for every API call: ```javascript Example: Token Cache theme={"dark"} let cachedToken = null; let tokenExpiry = null; async function getValidToken() { const now = Date.now(); // Return cached token if still valid (with 5-minute buffer) if (cachedToken && tokenExpiry && now < tokenExpiry - 300000) { return cachedToken; } // Request new token const response = await fetch('https://zuba-test.us.auth0.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: process.env.ZUBA_CLIENT_ID, client_secret: process.env.ZUBA_CLIENT_SECRET, audience: 'https://api.sandbox.zuba.com', grant_type: 'client_credentials' }) }); const data = await response.json(); // Cache token and expiry time cachedToken = data.access_token; tokenExpiry = now + (data.expires_in * 1000); return cachedToken; } ``` ## Scopes Every API key carries a set of scopes, granted when the key is created in the dashboard (**Developers → API Keys**). A **Read-only** key carries only `read:*` scopes; a **Full access** key carries every scope in the table below. The table lists the operations in this reference that check a scope. Other operations in this reference are authorized by the key's workspace alone, except the API key management operations, which accept only a dashboard session. Any `403 insufficient_scope` response names the scope to add, whether or not the operation appears here. | Scope | Gates | | - | - | | `read:payouts` | `GET /v1/payouts`, `GET /v1/payouts/{id}` | | `create:payouts` | `POST /v1/payouts`, `POST /v1/payouts/{id}/cancel`, `POST /v1/payouts/pop-documents/upload-url` | | `read:beneficiaries` | `GET /v1/beneficiaries`, `GET /v1/beneficiaries/{id}` | | `create:beneficiaries` | `POST /v1/beneficiaries`, `PUT /v1/beneficiaries/{id}` | | `read:orders` / `create:orders` | The [Orders](/guides/orders) endpoints | | `read:accounts` / `create:accounts` / `update:accounts` | The `/v1/accounts` endpoints | | `create:deposits` | `POST /v1/deposits`, `POST /v1/wallets` | | `create:conversions` | `POST /v1/conversions` | | `create:virtual-accounts` | `POST /v1/virtual-accounts` | | `create:webhooks` | `POST /v1/webhooks`, `PUT /v1/webhooks/{id}`, `DELETE /v1/webhooks/{id}`, `POST /v1/webhooks/{id}/rotate-secret`, `POST /v1/webhooks/{id}/test` | | `read:applications` / `create:applications` / `update:applications` | The `/v2/applications` endpoints | A request whose key lacks the scope for that endpoint is rejected with `403` and an `insufficient_scope` body naming the missing scope: ```json theme={"dark"} { "statusCode": 403, "error": "insufficient_scope", "message": "API key is missing required scope: read:payouts", "requiredScope": "read:payouts", "timestamp": "2026-09-09T10:30:00.000Z", "path": "/v1/payouts" } ``` If an integration receives `insufficient_scope` on an endpoint it needs, create a new key in the dashboard (**Developers → API Keys**) at the access level the integration needs, Full access or Read-only, switch the integration to it, and delete the old key. A token carries the scopes its key held when the token was issued, so request a new token with the new key rather than reusing a cached one (tokens live 24 hours). ## Environments Zuba provides separate environments for development and production: | Environment | Dashboard | Token URL | Audience | API Base URL | | - | - | - | - | - | | **Sandbox** | [https://sandbox.zuba.com](https://sandbox.zuba.com) | [https://zuba-test.us.auth0.com/oauth/token](https://zuba-test.us.auth0.com/oauth/token) | [https://api.sandbox.zuba.com](https://api.sandbox.zuba.com) | [https://api.sandbox.zuba.com](https://api.sandbox.zuba.com) | | **Production** | [https://dash.zuba.com](https://dash.zuba.com) | [https://auth.zuba.com/oauth/token](https://auth.zuba.com/oauth/token) | [https://api.zuba.com](https://api.zuba.com) | [https://api.zuba.com](https://api.zuba.com) | > [!NOTE] > > Always test in **Sandbox** before moving to production. Use separate credentials for each environment, and request tokens with that environment's token URL and audience; a token minted with the wrong pair is rejected with `401`. ## Authentication Errors ### Token Request Errors | Error | Cause | Solution | | - | - | - | | `access_denied` (`Unauthorized`) | Invalid Client ID or Client Secret | Verify credentials are correct and not expired | | `unsupported_grant_type` | Invalid grant\_type | Ensure grant\_type is `client_credentials` | | `access_denied` (`Service not found: ...`) | Wrong audience for the environment | Use the Audience value from the [Environments](#environments) table | ### API Request Errors | HTTP Status | Error | Cause | Solution | | - | - | - | - | | `401` | Unauthorized | Missing, invalid, or expired token | Request a new access token and retry | | `403` | Forbidden | The key lacks the scope the endpoint checks (`insufficient_scope`), or the workspace is not permitted the action | Grant the scope named in the response body (see [Scopes](#scopes)); otherwise contact support | Example error response: ```json theme={"dark"} { "statusCode": 401, "message": "Unauthorized" } ``` ## Security Best Practices > **Environment Variables** > > **Never hardcode credentials** in your application code. Use environment variables: > > ```bash .env theme={"dark"} > ZUBA_CLIENT_ID=YOUR_CLIENT_ID > ZUBA_CLIENT_SECRET=YOUR_CLIENT_SECRET > ZUBA_TOKEN_URL=https://zuba-test.us.auth0.com/oauth/token > ZUBA_AUDIENCE=https://api.sandbox.zuba.com > ZUBA_API_BASE_URL=https://api.sandbox.zuba.com > ``` > > ```javascript theme={"dark"} > const clientId = process.env.ZUBA_CLIENT_ID; > const clientSecret = process.env.ZUBA_CLIENT_SECRET; > ``` > **HTTPS Only** > > **Always use HTTPS** for both token requests and API calls. Never send credentials or tokens over HTTP. > **Credential Rotation** > > Rotate your credentials regularly. Two paths: > > * **Rotate the secret in place**: use the dashboard (**Developers → API Keys → Rotate**) or `POST /v1/api-keys/{clientId}/rotate-secret`. The Client ID stays the same, but the old secret stops authenticating immediately, so update your deployment right away. > * **Zero-downtime rotation** (or to rotate the Client ID itself): generate a new key, update your environment variables, deploy, then delete the old key in the dashboard. > **Token Storage** > > * Store tokens in memory, not in databases or files > * Never log tokens in application logs > * Clear tokens when they expire > * In serverless environments, cache the token outside the function instance where possible (warm-container memory or a secrets/KV cache) rather than minting a new token on every invocation > **Least Privilege** > > Mint a **Read-only** key for anything that only reads data, and keep Full access keys for the services that create payouts. See [Scopes](#scopes) for what each scope gates. ## Testing Authentication Test your authentication setup before making actual API calls. The JavaScript, Python, and Java samples reuse the token helper from [Step 2](#step-2-request-an-access-token): ```bash curl theme={"dark"} # Step 1: Get access token TOKEN=$(curl -s -X POST "https://zuba-test.us.auth0.com/oauth/token" \ -H "Content-Type: application/json" \ -d '{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "audience": "https://api.sandbox.zuba.com", "grant_type": "client_credentials" }' | jq -r '.access_token') # Step 2: Test API request curl -X GET "https://api.sandbox.zuba.com/v1/ledger/balances" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" ``` ```javascript JavaScript theme={"dark"} async function testAuthentication() { try { // Step 1: Get access token (getAccessToken from Step 2 above) const accessToken = await getAccessToken(); console.log('✅ Token obtained successfully'); // Step 2: Test API request const apiResponse = await fetch('https://api.sandbox.zuba.com/v1/ledger/balances', { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); if (apiResponse.ok) { console.log('✅ Authentication working correctly'); const data = await apiResponse.json(); console.log('Response:', data); } else { console.log('❌ API request failed:', apiResponse.status); } } catch (error) { console.error('❌ Error:', error); } } testAuthentication(); ``` ```python Python theme={"dark"} import requests def test_authentication(): try: # Step 1: Get access token (get_access_token from Step 2 above) access_token = get_access_token() print('✅ Token obtained successfully') # Step 2: Test API request api_response = requests.get( 'https://api.sandbox.zuba.com/v1/ledger/balances', headers={ 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } ) if api_response.ok: print('✅ Authentication working correctly') print('Response:', api_response.json()) else: print(f'❌ API request failed: {api_response.status_code}') except Exception as error: print(f'❌ Error: {error}') test_authentication() ``` ```java Java theme={"dark"} import java.net.http.*; import java.net.URI; import com.google.gson.Gson; import com.google.gson.JsonObject; public class TestAuthentication { public static void main(String[] args) { try { // Step 1: Get access token String accessToken = ZubaAuth.getAccessToken(); System.out.println("✅ Token obtained successfully"); // Step 2: Test API request HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/ledger/balances")) .header("Authorization", "Bearer " + accessToken) .header("Content-Type", "application/json") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { System.out.println("✅ Authentication working correctly"); System.out.println("Response: " + response.body()); } else { System.out.println("❌ API request failed: " + response.statusCode()); } } catch (Exception error) { System.err.println("❌ Error: " + error.getMessage()); } } } ``` A successful authentication test will return your account balances or an empty array if no balances exist yet. ## Next Steps > **[Quickstart Guide](/quickstart)** > > Start making your first API calls > **[API Reference](/api-reference/introduction)** > > Explore all available endpoints # Ledger System Source: https://docs.zuba.com/concepts/ledger Double-entry bookkeeping and transaction management ## Overview The Zuba ledger is a double-entry bookkeeping system that tracks every money movement on the platform. Each transaction is recorded as a transfer between accounts, giving you a complete audit trail and real-time balances. ## Core Concepts ### Accounts Each currency you hold is tracked in its own account. When querying your balances, you'll see: ```javascript Account Balance Response theme={"dark"} { "currency": "EUR", "balance": "1250.5", "pendingIn": "100", "pendingOut": "50", "totalBalance": "1300.5", "isActive": true, "lastTransactionAt": "2026-01-12T12:00:00Z" } ``` | Field | Description | | - | - | | `currency` | ISO 4217 currency code | | `balance` | Available balance (funds you can use) | | `pendingIn` | Incoming funds being processed | | `pendingOut` | Outgoing funds being processed | | `totalBalance` | Total balance including pending | | `isActive` | Whether the account is active | | `lastTransactionAt` | Timestamp of most recent transaction | `GET /v1/ledger/balances` lists the balance for every currency you hold, `GET /v1/ledger/balances/{currency}` returns a single currency, and `GET /v1/ledger/accounts` lists every account. Pass `includeInactive=true` to include closed accounts. > [!NOTE] > > All monetary values are returned as decimal strings to preserve precision. > Balance endpoints return normalized values with trailing zeros stripped > (`"1250.5"`, `"100"`), while transaction and statement amounts carry eight > decimal places (`"100.00000000"`). Parse amounts with a decimal library: > never parse them as floats or compare them as fixed-format text. ### Transactions All financial movements are recorded as double-entry transactions. When querying transactions, you'll see them from your account's perspective as debits (money out) or credits (money in): ```javascript Transaction Structure theme={"dark"} { "id": "572e779e-6d71-4bd7-91f1-57109c562b56", "type": "debit", "amount": "0.95411814", "currency": "EUR", "balanceAfter": "87.57104914", "counterparty": "ext:****3000", "status": "confirmed", "createdAt": "2026-01-12T12:50:48.792Z", "confirmedAt": "2026-01-12T12:50:48.791Z" } ``` | Field | Description | | - | - | | `id` | Unique transaction identifier | | `type` | `debit` (money out) or `credit` (money in) | | `amount` | The entry's own amount. On a deposit that carries a fee, the gross credit, before the `feeAmount` deducted from it | | `currency` | ISO 4217 currency code | | `balanceAfter` | Your account balance after this entry. On a deposit that carries a fee, the balance once the credit and that fee have both been applied | | `feeAmount` | Present on a deposit entry that carries a fee: the total charged with it, in the entry currency, already deducted from the balance | | `netAmount` | Present wherever `feeAmount` is: `amount` less `feeAmount`, what the deposit added to your balance | | `counterparty` | The external counterparty, masked (e.g. `ext:****3000`). Internal platform accounts are omitted, so this field is absent on internal transfers (such as fees and FX) | | `isFee` | `true` when the entry is a fee in its own right: a fee charged to you (a payout's fee), or one returned to you (a payout that failed before it was paid has its fee refunded; a payout the beneficiary's bank returns after it was paid has its fee refunded only when `feeRefunded` on the payout is `true`). A fee booked with a deposit is reported on that deposit's entry as `feeAmount` rather than as an entry; a fee booked at any other time is an entry of its own. Lets you label fee entries without inspecting account names | | `status` | Transaction status (`pending`, `confirmed`, `failed`, `cancelled`) | | `createdAt` | When the transaction was created | | `confirmedAt` | When the transaction was confirmed | | `referenceId` | Present when the entry links to an originating operation | | `description` | Present when the entry carries a human-readable description | | `method` | Rail discriminator: the payout route or deposit method that produced the entry | | `payoutId` | Present on payout entries: the originating payout's id | | `payoutStatus` | Present on payout entries: the payout's delivery status (`created`, `queued`, `processing`, `paid`, `failed`, `cancelled`) | | `depositId` | Present on deposit entries: the originating deposit's id | | `orderId` | Present on trade-desk order entries: the originating order's id | | `network` | Present on crypto entries: the on-chain network | | `asset` | Present on crypto entries: the stablecoin asset (`USDC`, `USDT`) | | `txHash` | Present on crypto entries: the on-chain transaction hash, for looking the transfer up on a block explorer | | `sourceAmount` / `sourceCurrency` | Present on FX payout entries: the amount and currency debited from your balance (see [Currency Conversion Tracking](#currency-conversion-tracking)) | | `payoutAmount` / `payoutCurrency` | Present on FX payout entries: the amount and currency delivered to the beneficiary | Optional fields are omitted when not applicable. A fee entry of its own carries `isFee: true`; it is an internal transfer, so no `counterparty` is present. > [!NOTE] > > The fees booked with a deposit are reported on the entry of the credit they > were booked with, not as entries of their own: `amount` is the gross credit, > `feeAmount` the total charged, and `netAmount` what reached your balance. A > fee booked at any other time is an entry of its own, flagged `isFee`. A > payout's fee is never reported this way: a crypto payout is up to two > entries, the principal and its fee where one is charged, and a bank or FX > payout is one entry whose `amount` is the total debited from your balance, > fee included. > [!NOTE] > > A payout's ledger transaction is `confirmed` as soon as the funds are debited > from your balance. It does not mean the beneficiary has been paid. Slow > rails (e.g. SWIFT) can remain in flight for days after the debit confirms. > Use `payoutStatus` to track delivery; `paid` means the funds arrived. See > [payout statuses](/concepts/payouts#payout-statuses) for the full lifecycle. ## Transaction States ```mermaid theme={"dark"} stateDiagram-v2 [*] --> pending pending --> confirmed pending --> failed pending --> cancelled confirmed --> [*] failed --> [*] cancelled --> [*] ``` | Status | Description | Counts toward | | - | - | - | | `pending` | Transaction created, awaiting confirmation | `pendingIn` / `pendingOut` | | `confirmed` | Transaction successfully processed | `balance` | | `failed` | Transaction failed during processing | Nothing (excluded from balances) | | `cancelled` | Transaction cancelled before processing | Nothing (excluded from balances) | These transitions are managed entirely by the platform. There is no API call to confirm, cancel, or retry a ledger transaction. Failed payouts are handled with reversal entries (see [Transaction Failures](#transaction-failures)). ## Balance Management ### Real-time Balance Calculation Your available `balance` is a stored running balance, updated as each transaction confirms. Pending amounts are computed at query time, so every balance response satisfies: ```javascript theme={"dark"} // Balance invariant totalBalance = balance + pendingIn - pendingOut ``` ### Multi-Currency Support Each account maintains a balance in a single currency. When querying all balances, you'll receive one entry per currency: ```javascript Multi-Currency Response theme={"dark"} { "userId": "auth0|123456", "clientId": "cc82fa1d-fc7a-478c-a734-d3bce40464e7", "balances": [ { "currency": "EUR", "balance": "1000", "pendingIn": "0", "pendingOut": "0", "totalBalance": "1000" }, { "currency": "USD", "balance": "1200", "pendingIn": "50", "pendingOut": "0", "totalBalance": "1250" } ], "primaryCurrency": "EUR", "calculatedAt": "2026-01-12T12:00:00Z" } ``` The response also includes a `totalValueEur` field: an indicative total of all balances converted to EUR at the latest stored mid rates, with stablecoins valued at their fiat peg. It is omitted when no rate is available to value one of your held currencies. Treat it as a display convenience, not an accounting figure: derive reconciliation totals from the per-currency balances. ## Payment Flow Integration > [!NOTE] > > Ledger transactions are created automatically by the platform when processing deposits and payouts. You cannot create transactions directly. They are generated as a result of your payment operations. ### Payin Transaction Flow When a deposit settles, the platform moves the funds internally and credits your account automatically. From your perspective it lands as a single credit: ```mermaid theme={"dark"} flowchart LR A[Payment source] --> B[Platform account] B --> C[Your account] ``` You'll see the credit appear in your transaction history. The internal account that funded it is omitted, so no `counterparty` is present: ```javascript Credit Transaction theme={"dark"} { "id": "572e779e-6d71-4bd7-91f1-57109c562b56", "type": "credit", "amount": "100.00000000", "currency": "EUR", "feeAmount": "1.25000000", "netAmount": "98.75000000", "balanceAfter": "1098.75000000", "depositId": "d1e2f3a4-5b6c-7d8e-9f0a-1b2c3d4e5f6a", "status": "confirmed", "createdAt": "2026-01-12T12:00:00.000Z", "confirmedAt": "2026-01-12T12:00:00.000Z" } ``` `feeAmount` and `netAmount` are absent on a deposit that carries no fee, where `amount` is already what reached your balance. ### Payout Transaction Flow For payouts, the system creates transaction chains including FX conversion when needed: ```mermaid theme={"dark"} sequenceDiagram participant Customer participant Platform participant FX participant Rail participant Beneficiary Customer->>Platform: Debit funds Platform->>FX: Convert currency (if needed) FX->>Rail: Send converted amount Rail-->>Beneficiary: Funds delivered ``` You'll see the debit in your transaction history. The internal accounts used to route and convert the payout are omitted, so no `counterparty` is present: ```javascript Debit Transaction theme={"dark"} { "id": "683f880f-7e82-5ce8-b202-68210d673c67", "type": "debit", "amount": "100.00000000", "currency": "EUR", "balanceAfter": "900.00000000", "payoutId": "b7f1c2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "payoutStatus": "processing", "status": "confirmed", "createdAt": "2026-01-12T14:30:00.000Z", "confirmedAt": "2026-01-12T14:30:00.000Z" } ``` ## Querying the Ledger ### Account Balance ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/ledger/balances/EUR" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ```javascript JavaScript theme={"dark"} const balance = await fetch('https://api.sandbox.zuba.com/v1/ledger/balances/EUR', { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } }); const data = await balance.json(); console.log(`Balance: ${data.balance} ${data.currency}`); ``` ```python Python theme={"dark"} import requests balance = requests.get( 'https://api.sandbox.zuba.com/v1/ledger/balances/EUR', headers={ 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } ) data = balance.json() print(f"Balance: {data['balance']} {data['currency']}") ``` ```java Java theme={"dark"} HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/ledger/balances/EUR")) .header("Authorization", "Bearer YOUR_ACCESS_TOKEN") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); JsonObject data = new Gson().fromJson(response.body(), JsonObject.class); System.out.println("Balance: " + data.get("balance").getAsString() + " " + data.get("currency").getAsString()); ``` ### Transaction History `GET /v1/ledger/transactions` returns a JSON array of transactions, newest first. Filter with `currency`, and paginate with `limit` (1–500) and `offset`: ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/ledger/transactions?currency=EUR&limit=50" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ```javascript JavaScript theme={"dark"} const response = await fetch('https://api.sandbox.zuba.com/v1/ledger/transactions?currency=EUR&limit=50', { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } }); // The response body is a JSON array of transactions const transactions = await response.json(); transactions.forEach(tx => { const direction = tx.type === 'credit' ? '+' : '-'; // A charged deposit reports the gross in `amount` and what reached the // balance in `netAmount`, so step the balance by the net where there is one. const moved = tx.netAmount ?? tx.amount; console.log(`${direction}${moved} ${tx.currency} → Balance: ${tx.balanceAfter}`); }); ``` ```python Python theme={"dark"} import requests response = requests.get( 'https://api.sandbox.zuba.com/v1/ledger/transactions', params={'currency': 'EUR', 'limit': 50}, headers={ 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } ) # The response body is a JSON array of transactions for tx in response.json(): direction = '+' if tx['type'] == 'credit' else '-' # A charged deposit reports the gross in `amount` and what reached the # balance in `netAmount`, so step the balance by the net where there is one. moved = tx.get('netAmount', tx['amount']) print(f"{direction}{moved} {tx['currency']} → Balance: {tx['balanceAfter']}") ``` ```java Java theme={"dark"} HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/ledger/transactions?currency=EUR&limit=50")) .header("Authorization", "Bearer YOUR_ACCESS_TOKEN") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); // The response body is a JSON array of transactions JsonArray transactions = new Gson().fromJson(response.body(), JsonArray.class); transactions.forEach(tx -> { JsonObject transaction = tx.getAsJsonObject(); String direction = transaction.get("type").getAsString().equals("credit") ? "+" : "-"; // A charged deposit reports the gross in `amount` and what reached the // balance in `netAmount`, so step the balance by the net where there is one. JsonElement net = transaction.get("netAmount"); String moved = net == null ? transaction.get("amount").getAsString() : net.getAsString(); System.out.println(direction + moved + " " + transaction.get("currency").getAsString() + " → Balance: " + transaction.get("balanceAfter").getAsString()); }); ``` ## Audit & Compliance ### Audit Trail Every transaction maintains a complete audit trail: * **Immutable Records**: Transactions cannot be modified once confirmed * **Timestamps**: Every entry carries creation and confirmation timestamps * **Metadata**: Additional context stored with each transaction * **Reference IDs**: Link each entry to the originating payout, deposit, or order ### Compliance Features > **Financial Reconciliation** > > * Daily balance reconciliation across all accounts > * Settlement matching and verification on every rail > * Automated discrepancy detection and alerts > * Historical balance reconstruction for any past date > **Reporting** > > * Transaction history export for audits > * Account statements for any period > * End-of-day closing balances for daily reconciliation > **Data Integrity** > > * Double-entry validation on every transaction > * Balance consistency checks across all accounts > * Immutable transaction records with cryptographic hash chaining ## Advanced Features ### Currency Conversion Tracking When a payout converts currency, you still see a single debit on your account. It carries both the amount debited from your balance and the amount sent to the beneficiary, via the `sourceAmount`/`sourceCurrency` and `payoutAmount`/`payoutCurrency` fields. Internal FX accounts are not surfaced, so no `counterparty` is present: ```javascript FX Payout Debit theme={"dark"} { "id": "572e779e-6d71-4bd7-91f1-57109c562b56", "type": "debit", "amount": "100.00000000", "currency": "EUR", "balanceAfter": "900.00000000", "sourceAmount": "100.00", "sourceCurrency": "EUR", "payoutAmount": "165000.00", "payoutCurrency": "NGN", "payoutId": "b7f1c2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "payoutStatus": "processing", "status": "confirmed", "createdAt": "2026-01-12T12:50:48.792Z", "confirmedAt": "2026-01-12T12:50:48.791Z" } ``` The FX rate applied to your transaction is visible in the payout details, not the ledger transaction itself. ### Account Statements For detailed reconciliation, you can generate account statements for a specific period: ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/ledger/statement/EUR?startDate=2026-01-01&endDate=2026-01-31" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ```javascript JavaScript theme={"dark"} const response = await fetch( 'https://api.sandbox.zuba.com/v1/ledger/statement/EUR?startDate=2026-01-01&endDate=2026-01-31', { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } } ); const statement = await response.json(); console.log(`Opening balance: ${statement.openingBalance}`); console.log(`Closing balance: ${statement.closingBalance}`); console.log(`Total credits: ${statement.totalCredits}`); console.log(`Total debits: ${statement.totalDebits}`); ``` **Statement Response:** ```javascript Account Statement theme={"dark"} { "accountId": "123e4567-e89b-12d3-a456-426614174000", "accountName": "user:cc82fa1d-fc7a-478c-a734-d3bce40464e7", "currency": "EUR", "periodStart": "2026-01-01", "periodEnd": "2026-01-31", "openingBalance": "1000.00000000", "closingBalance": "1500.50000000", "totalCredits": "600.50000000", "totalDebits": "100.00000000", "transactions": [ { "date": "2026-01-15T10:00:00Z", "credit": "500.00000000", "debit": "1.25000000", "balanceAfter": "1498.75000000", "reference": "DEPOSIT-001", "transactionId": "abc123..." } ], "generatedAt": "2026-01-31T12:00:00Z" } ``` Statement amounts (`openingBalance`, `closingBalance`, `totalCredits`, `totalDebits`, and per-transaction `debit`/`credit`) are fixed to eight decimal places. `openingBalance` is the account's balance at the start of `startDate` (UTC) and `closingBalance` is its balance at the end of `endDate` (UTC), so `openingBalance` plus `totalCredits` less `totalDebits` is `closingBalance`. Both are derived from the account's own ledger entries. A statement whose `endDate` has passed is final: it reads the same however much has been booked since. A statement whose `endDate` is today or later closes on the balance so far, and the same request answers differently once more activity is booked. A deposit and the fees charged with it are one line of this statement: the fees do not appear as lines of their own. `credit` is the gross deposit and `debit` the total charged with it. Both sides are counted in `totalCredits` and `totalDebits`, so `closingBalance` remains `openingBalance + totalCredits - totalDebits`, and every line lands on a balance the account held. ### Closing Balances For day-level reconciliation, `GET /v1/ledger/closing-balances` returns the end-of-day balance of every account for a past UTC calendar date. The date must be yesterday or earlier. Today and future dates are rejected because today is not yet closed: ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/ledger/closing-balances?date=2026-01-31" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ```javascript Closing Balances Response theme={"dark"} [ { "accountId": "123e4567-e89b-12d3-a456-426614174000", "currency": "EUR", "closingBalance": "1500.50", "date": "2026-01-31" } ] ``` Accounts created after the requested date are excluded, so a missing currency means the account did not exist yet, not a zero balance. This endpoint reports the end-of-day balance recorded for each account on that date, whereas a statement derives its figures from the account's own entries. Compare a statement's `openingBalance` or `closingBalance` against the entries in the period, not against this endpoint. ## Error Handling ### Transaction Failures When a payout fails, the ledger does not rewrite history. The original debit stays `confirmed` (the funds left your balance when the payout was created), and the platform posts a matching reversal credit that restores them. The debit's `payoutStatus` becomes `failed`; the reversal credit carries the same `payoutId`: ```javascript Failed Payout: Debit and Reversal Credit theme={"dark"} [ { "id": "794a991a-8f93-6df9-c313-79321e784d78", "type": "credit", "amount": "100.00000000", "currency": "EUR", "balanceAfter": "1000.00000000", "payoutId": "b7f1c2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "status": "confirmed", "createdAt": "2026-01-12T13:05:00.000Z", "confirmedAt": "2026-01-12T13:05:00.000Z" }, { "id": "683f880f-7e82-5ce8-b202-68210d673c67", "type": "debit", "amount": "100.00000000", "currency": "EUR", "balanceAfter": "900.00000000", "payoutId": "b7f1c2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "payoutStatus": "failed", "status": "confirmed", "createdAt": "2026-01-12T12:50:00.000Z", "confirmedAt": "2026-01-12T12:50:00.000Z" } ] ``` The net balance impact is zero, and both entries remain in your history for auditability. The one exception is a payout the beneficiary's bank returns after it was paid whose refund kept the fee (`feeRefunded: false` on the payout): it also ends `failed`, but its reversal credit is the principal alone, the debit less the fee, because the payout fee stands on a payment that was executed, so the net impact is the fee. ### Common Error Scenarios | Error | Description | Resolution | | - | - | - | | Insufficient funds (`INSUFFICIENT_BALANCE`) | Not enough balance to cover the payout and its fees | Add funds, then send the payout again with the same `clientRef` (see [Error Recovery](/guides/error-handling#error-recovery)) | | Invalid beneficiary | Beneficiary details are incorrect | Update the beneficiary information | | Rail failure | The payment rail could not deliver the transfer | Funds are reversed automatically. Retry or contact support | ## Integration Guidelines > [!NOTE] > > Ledger transactions are created automatically by the platform when you create payouts or receive deposits. You cannot create transactions directly. Use the [Payouts API](/concepts/payouts) to initiate transfers. ### Best Practices > **Balance Monitoring** > > * Check balances before initiating payouts to avoid failures > * Subscribe to [webhooks](/guides/webhooks) for payout and order lifecycle events that affect your balance, then re-query `/v1/ledger/balances` on receipt > * Use the statement and closing-balances endpoints for reconciliation > * Monitor pending amounts for cash flow planning > **Reconciliation** > > * Generate statements periodically for your records > * Verify end-of-day positions with `/v1/ledger/closing-balances` > * Match ledger transactions with your internal systems > * Export transaction history for accounting integration > **API Usage** > > * Use pagination (`limit` and `offset`) for large transaction histories > * Filter by currency when querying specific account activity > * Cache balance responses where real-time accuracy isn't required > * Handle rate limits gracefully with exponential backoff ## Next Steps > **[First Payout Guide](/guides/first-payout)** > > Send your first payout with the Zuba API > **[Webhooks](/guides/webhooks)** > > Set up real-time notifications for payment events > **[Batch Payouts](/guides/batch-payouts)** > > Process multiple payouts efficiently > **[API Reference](/api-reference/introduction)** > > Complete API documentation # Platform Overview Source: https://docs.zuba.com/concepts/overview Understanding the Zuba Payment Platform architecture ## System Architecture The Zuba Payment Platform is built around several core components that work together to provide comprehensive payment processing capabilities: > **[Payouts Engine](/concepts/payouts)** > > Send money globally via international bank transfers, SWIFT, US rails (ACH and Fedwire), local bank rails, mobile money, and crypto networks, with real-time tracking and compliance. > **[Payment Acceptance](/concepts/payins)** > > Accept payments via bank-transfer deposits with unique account details per deposit, plus crypto deposit addresses. Funds are credited to your ledger on receipt. > **[Ledger System](/concepts/ledger)** > > Double-entry accounting with multi-currency support, real-time balance tracking, and comprehensive audit trails. > **[Global Rail Coverage](/concepts/payouts#payment-routes)** > > Payout rails and FX corridors across Europe, the US, Africa, and major crypto networks, with automatic route selection. ## Data Flow ```mermaid theme={"dark"} graph LR A[Client Application] --> B[Zuba API] B --> C[Authentication Layer] C --> D[Business Logic] D --> E[Ledger System] D --> F[Payment Rails] F --> G[Banks / Wallets / Mobile Networks] E --> H[Database] D --> I[Webhooks] I --> A ``` ## Key Entities ### Beneficiaries The people and businesses you pay, managed via `GET/POST /v1/beneficiaries`. Each beneficiary carries: * Personal information (name, email, address) * Multiple payout accounts (bank accounts, crypto wallets, mobile money) ### Accounts Payment destinations attached to a beneficiary: * **Bank Accounts**: IBAN, US routing and account numbers, SWIFT details, or local bank codes * **Crypto Wallets**: Wallet address and network (Ethereum, Solana, or Tron) * **Mobile Money**: Operator, phone number, and country ### Transactions All payment activity is recorded in the ledger and retrievable via `GET /v1/ledger/transactions`: * **Payouts**: Outbound payments to beneficiaries * **Payins**: Incoming deposits from your customers * **Conversions**: Currency conversion records ## Payout Statuses ```mermaid theme={"dark"} stateDiagram-v2 [*] --> created created --> queued queued --> processing processing --> paid processing --> failed created --> cancelled queued --> cancelled failed --> [*] paid --> [*] cancelled --> [*] ``` A payout can be cancelled (`POST /v1/payouts/{id}/cancel`) only while it is `created` or `queued`. A `failed` payout is not retried in place. Review the error and create a new payout. A receipt can be downloaded in any status except `cancelled`. See [Payout Statuses](/concepts/payouts#payout-statuses) for the full status reference. ## Multi-Currency Support Zuba supports payouts in EUR, USD, GBP, NGN, GHS, KES, UGX, XOF, and XAF, plus the stablecoins USDC and USDT, with real-time conversion between supported corridors: * **Fiat Currencies**: EUR, USD, GBP, NGN, GHS, KES, UGX, XOF, XAF * **Stablecoins**: USDC, USDT Use `GET /v1/conversions/corridors` to list the currency pairs available to your account. ### Currency Conversion * Real-time exchange rates * Transparent fee structure * Rate locking via quotes (`POST /v1/quotes`), with a dedicated [orders channel](/guides/orders) for amounts above corridor limits * Every conversion records the rate applied, retrievable via `GET /v1/conversions` * A conversion made with the `Zuba-Account-Id` header converts on that sub-account's own balance and carries that sub-account's id as `accountId`, and `GET /v1/conversions` with the header lists only that sub-account's conversions (see [Sending from a sub-account](/concepts/payouts#sending-from-a-sub-account)) ## Compliance Framework Built-in compliance features ensure regulatory adherence: > **KYC (Know Your Customer)** > > * Identity verification for beneficiaries > * Document collection and validation > * Risk assessment and scoring > * Ongoing monitoring and updates > **AML (Anti-Money Laundering)** > > * Transaction monitoring and screening > * Sanctions and watchlist screening > * Suspicious activity reporting > * Transaction limits and controls > **Regulatory Reporting** > > * Transaction reporting where required by authorities > * Audit trail maintenance > * Data retention policies ## Payment Rails & Routing Zuba routes each payout over the rail best suited to the destination currency and account type: * **International transfers**: EUR and GBP delivered to IBAN accounts * **SWIFT**: International wires (USD) * **ACH and Fedwire**: US domestic rails (USD) * **Local bank transfers**: Nigeria (NGN), Ghana (GHS), Kenya (KES), Uganda (UGX), Cameroon (XAF), and Côte d'Ivoire (XOF) * **Mobile money**: West Africa (XOF), Cameroon (XAF), Ghana (GHS), Kenya (KES), and Uganda (UGX) * **Crypto networks**: Stablecoin payouts on Ethereum, Solana, and Tron See [Payment Routes](/concepts/payouts#payment-routes) for corridor details, speeds, and route values. ### Route Selection The route is determined automatically from the beneficiary's account type and currency, balancing: * Destination country and currency * Cost and speed * Reliability * Compliance requirements ## Security & Infrastructure ### Data Security * Encryption of sensitive data at rest and in transit * Independent security audits and penetration testing * API credentials with secret rotation ### Infrastructure * Auto-scaling based on demand * Comprehensive monitoring and alerting * Disaster recovery and backup systems ## Getting Started Ready to integrate? Here's what you need to know: 1. **Base URLs**: `https://api.zuba.com` for production, `https://api.sandbox.zuba.com` for Sandbox 2. **Authentication**: Exchange your API credentials for an access token (see [Authentication](/authentication)) 3. **Webhooks**: Set up endpoints for real-time notifications (see the [Webhooks guide](/guides/webhooks)) 4. **Testing**: Build and test in Sandbox before going live (see [Sandbox Testing](/guides/sandbox-testing)) > [!NOTE] > > The platform is designed to be developer-friendly, with comprehensive APIs and detailed documentation for easy integration. # Payins Source: https://docs.zuba.com/concepts/payins Fund your Zuba balance with bank transfers and crypto deposits ## Overview Payins fund your Zuba balance. These deposit methods are live today: * **NGN bank transfers**: transfers into your standing virtual account are credited automatically; dynamic per-deposit virtual accounts are also available via the API. * **EUR & GBP bank transfers**: transfers into your dedicated named accounts (EUR IBAN via SEPA, GBP sort code + account number via Faster Payments) are credited automatically. * **USD bank deposits**: transfers into your USD receiving account, over ACH, Fedwire or SWIFT, are credited automatically. * **KES deposits**: fund your KES virtual account by bank transfer (PesaLink, RTGS, or SWIFT) or over M-Pesa — either an STK-push pull you initiate with `POST /v1/deposits`, or a customer paybill deposit. Every rail credits the same account. * **ZAR bank transfers**: transfers into your ZAR virtual account are credited automatically. The account shares its bank account number with other ZAR accounts, so every transfer must carry the account's `paymentReference`; see [ZAR deposits](#zar-deposits). * **XOF & XAF bank transfers**: receiving accounts fund your [trade-desk orders](/guides/orders). Upload evidence only when the returned settlement has `requirements.proofOfPayment: true`; other orders reconcile without a proof upload. * **Crypto deposits**: provision an on-chain address and receive USDC or USDT, credited on confirmation. Live accounts apply a per-transfer minimum, below which a transfer is not credited. Zuba records each accepted deposit in the ledger and credits your balance once funds are received. ## NGN Deposits ### Standing virtual account Your workspace can hold a dedicated NGN virtual account in your business name (`Zuba {Merchant Name}`). Transfers sent to it are credited to your NGN balance automatically, and the same account number stays payable indefinitely. They appear as deposits in `GET /v1/deposits` and on the `deposit.completed` webhook. An NGN account records each deposit as `bank_deposit` or as `virtual_account`, depending on the bank that serves the account and, for older deposits, on when the deposit arrived, so match on both values. Request one with `POST /v1/virtual-accounts` once your business verification is approved: ```json Create Virtual Account Request theme={"dark"} { "currency": "NGN" } ``` The call is idempotent per currency: if you already hold a live NGN account it is returned with a `200` instead of a `201`. Currencies you name during onboarding are requested for you when your verification is approved, so you may already have one — fetch the details with `GET /v1/virtual-accounts/me`: ```json Get My Virtual Account Response theme={"dark"} { "currency": "NGN", "accountNumber": "8012345678", "accountName": "Zuba Acme Ltd", "bankName": "Wema Bank", "status": "active" } ``` The account details are also shown in the dashboard. `GET /v1/virtual-accounts/requirements?currency=NGN` reports anything still outstanding before one can be issued. ### Dynamic virtual accounts (per deposit) Alternatively, create a deposit with `POST /v1/deposits` to receive one-off virtual account details for a specific sender. NGN deposits created this way require the sender's `firstName`, `lastName`, `email`, and `phoneNumber` to generate the virtual account: ```bash curl theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/deposits" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "amount": "50000.00", "currency": "NGN", "clientRef": "DEPOSIT-REF-001", "description": "Account top-up", "firstName": "John", "lastName": "Doe", "email": "john.doe@example.com", "phoneNumber": "+2348012345678" }' ``` ```javascript JavaScript theme={"dark"} const deposit = await fetch('https://api.sandbox.zuba.com/v1/deposits', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: '50000.00', currency: 'NGN', clientRef: 'DEPOSIT-REF-001', description: 'Account top-up', firstName: 'John', lastName: 'Doe', email: 'john.doe@example.com', phoneNumber: '+2348012345678' }) }); const data = await deposit.json(); ``` The response contains the virtual account details to display to the sender: ```json Create Deposit Response (NGN) theme={"dark"} { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "method": "manual_deposit", "amount": "50000.00", "currency": "NGN", "status": "pending", "iban": "3234567890", "bic": "Example Bank", "reference": "DEP-REF-1706510200000", "description": "Account top-up", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-15T10:30:00Z", "metadata": { "providerReference": "DEP-REF-1706510200000", "accountNumber": "3234567890", "accountName": "John Doe", "bankName": "Example Bank", "expiresAt": "2024-01-16T10:30:00Z" } } ``` > [!NOTE] > > The `iban` and `bic` field names are shared across deposit methods. For NGN > deposits, `iban` carries the virtual account number and `bic` carries the > bank name. Display the `metadata` block to the sender: `accountNumber`, > `accountName`, `bankName`, and `expiresAt` are the details they need to > complete the transfer before the virtual account expires. > [!NOTE] > > In the sandbox no bank account is opened and nothing needs to be paid in: > the deposit settles itself to `completed` a few seconds after it is created. > See [Sandbox testing](/guides/sandbox-testing#deposits). ## USD Bank Deposits A USD receiving account is held in your business name and reachable over three rails: **ACH**, **Fedwire**, and **SWIFT**. Transfers sent to its account and routing numbers are credited to your USD balance automatically, with no create call per transfer, and appear as deposits in `GET /v1/deposits`. The account details are shown in the dashboard and returned by the API once the account is active. ## EUR & GBP Bank Deposits Your workspace can be provisioned with dedicated named accounts in your business name: an **EUR IBAN** reachable over SEPA and a **GBP account number + sort code** reachable over Faster Payments. Transfers sent to them are credited to the matching balance automatically (no create call is required) and appear as deposits with method `bank_deposit` in `GET /v1/deposits` and on the `deposit.completed` webhook. Deposits created before this field settled carry `virtual_account` instead, so match on both values when reading a full deposit history. The account details are shown in the dashboard. Contact your account manager to provision named accounts. ## KES Deposits Every KES collection is received into a **KES virtual account** — it is the prerequisite for all the methods below, and each of them settles funds into it. Request one with `POST /v1/virtual-accounts`: ```json Create Virtual Account Request theme={"dark"} { "currency": "KES" } ``` Issuance is asynchronous and gated on your Kenya business verification. `GET /v1/virtual-accounts/requirements?currency=KES` reports anything still outstanding before one can be issued — see [Requesting an account](#requesting-an-account) for the full flow. Every deposit below appears in `GET /v1/deposits`. ### Bank transfer Send funds to your KES virtual account number over **PesaLink**, **RTGS**, or **SWIFT** (SWIFT/BIC `CHFIKENX`). Transfers are credited to your KES balance automatically — no create call per transfer — and appear with method `bank_deposit` in `GET /v1/deposits` and on the `deposit.completed` webhook. Deposits created before this field settled carry `virtual_account` instead, so match on both values when reading a full deposit history. ### Mobile money (M-Pesa) Two ways to collect over M-Pesa, both crediting the same KES virtual account. #### STK push Pull a specific amount by prompting the payer's phone: create a deposit with `POST /v1/deposits`, `method: "mobile_money"`, and the payer's `phoneNumber`. The payer keys in their M-Pesa PIN on the prompt to approve the transfer. ```bash curl theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/deposits" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "amount": "500", "currency": "KES", "method": "mobile_money", "clientRef": "DEPOSIT-REF-002", "description": "Account top-up", "phoneNumber": "+254712345678" }' ``` ```javascript JavaScript theme={"dark"} const deposit = await fetch('https://api.sandbox.zuba.com/v1/deposits', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: '500', currency: 'KES', method: 'mobile_money', clientRef: 'DEPOSIT-REF-002', description: 'Account top-up', phoneNumber: '+254712345678' }) }); const data = await deposit.json(); ``` The request returns a `processing` deposit once the prompt is sent; the credit is confirmed asynchronously after the payer approves it: ```json Create Deposit Response (KES STK push) theme={"dark"} { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "method": "mobile_money", "amount": "500", "currency": "KES", "status": "processing", "reference": "STK-1706510200000", "description": "STK push sent — approve the prompt on your phone to complete.", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-15T10:30:00Z" } ``` > [!NOTE] > > KES STK-push deposits have three input rules: > > * `method` must be `mobile_money` — KES does not accept the default `manual_deposit`. > * `amount` must be a **whole KES number greater than zero**; M-Pesa does not settle fractional shillings. > * `phoneNumber` is the payer's MSISDN the prompt is sent to. Poll `GET /v1/deposits/{id}` for the outcome — the deposit moves to `completed` once the payer approves the prompt and M-Pesa settles, or `failed` if the prompt is declined or times out. > [!NOTE] > > In the sandbox no prompt is sent: the deposit settles itself a few seconds > after it is created, to `failed` for the documented failure number and to > `completed` for any other number. M-Pesa collections must be enabled for > the workspace first; until they are, the create returns `403` > `METHOD_NOT_ENTITLED`. See > [Sandbox testing](/guides/sandbox-testing#deposits). #### Paybill deposit The payer pushes funds themselves: they pay the Zuba paybill, entering **your KES virtual account number as the account number**. No create call is needed — the credit is matched to your account and appears with method `bank_deposit`, like a bank transfer into the account (older deposits carry `virtual_account`; match on both values when reading a full history). The paybill number is `444174`. In the sandbox no paybill payment can reach your account. Simulate one with the [deposit simulator](/guides/sandbox-testing#deposits-to-a-virtual-account) instead, sending your KES virtual account number as `accountNumber`. ## ZAR Deposits A ZAR virtual account is issued with `POST /v1/virtual-accounts` and `"currency": "ZAR"`. Its bank details are a South African bank account held in Zuba's name that several ZAR accounts share: what makes a transfer yours is the account's `paymentReference`, which the payer must put on the transfer. A transfer sent without it cannot be matched to your account automatically; contact support if one was sent. ```json Create Virtual Account Response theme={"dark"} { "currency": "ZAR", "accountNumber": "001234567", "accountName": "Zuba", "bankName": "Standard Bank", "paymentReference": "ZB4829173650", "status": "active" } ``` Give the payer the bank, the account name, the account number and the payment reference. A ZAR account may be returned `pending` with no bank details; it is absent from `GET /v1/virtual-accounts` until it is issued, and the `virtual_account.active` webhook announces when it is. A credited transfer appears in `GET /v1/deposits` as a `bank_deposit` and on the `deposit.completed` webhook. ## Requesting an account Request an account over the API with `POST /v1/virtual-accounts`, for your own workspace or for a customer account you manage (pass its id in `Zuba-Account-Id`). Issuance is asynchronous: the request answers immediately with a `pending` account, and the account becomes usable once the receiving bank opens it. Subscribe to the `virtual_account.active` webhook to learn when its details are available, and `virtual_account.failed` to learn that it will not be opened. Requesting again while one is pending returns the same account rather than opening a second. An account can only be opened once the owner has an approved application carrying everything the receiving bank asks for. Those attributes vary by currency and are not all collected during onboarding, so rather than discovering a gap from a failed request, ask first: ```bash Check what is outstanding theme={"dark"} curl "https://api.zuba.com/v1/virtual-accounts/requirements?currency=USD" \ -H "Authorization: Bearer $TOKEN" \ -H "Zuba-Account-Id: $ACCOUNT_ID" ``` ```json Requirements Response theme={"dark"} { "currency": "USD", "ready": false, "status": "action_required", "requirements": [ { "code": "business.industry_code", "scope": "business", "description": "The 6-digit NAICS code describing what the business does.", "resolution": { "method": "POST", "path": "/v2/applications/6f1c.../enrich", "field": "industryCode" }, "disclosures": null }, { "code": "ubo.occupation_code", "scope": "person", "description": "The 6-digit SOC code for the beneficial owner's occupation.", "resolution": { "method": "POST", "path": "/v2/applications/6f1c.../persons/8dbb.../enrich", "field": "occupationCode" }, "disclosures": null }, { "code": "disclosure.acknowledgement", "scope": "disclosure", "description": "The terms this account would be opened under have not all been accepted at the version currently in force. Every outstanding document is listed in `disclosures`, each with the exact wording that must be agreed. They are accepted while the account owner's application is still open; for an owner whose application is already approved, contact support.", "resolution": null, "disclosures": [ { "slug": "funds-availability-disclosure", "title": "Funds Availability Disclosure", "version": "2026.08.07", "pdfUrl": "/disclosures/acme/v2026.08.07/funds-availability-disclosure.pdf", "displayOrder": 30, "checkboxWording": "I have read and acknowledge the Funds Availability Disclosure." } ] } ] } ``` Branch on `ready`. When it is `false`, each entry names what is outstanding and, in `resolution`, the exact call that supplies it — already resolved down to the specific person where the value belongs to one. Send those, then request the account. Read `code` rather than `description`: the codes are stable, the prose is not. A requirement whose `resolution` is `null` is one no API call sets — a detail recorded when the application was reviewed, acceptance the account owner owes for the terms the account would be opened under, or a state of the account or workspace rather than a value. Its `description` says what clears it. The `disclosure.*` codes are that acceptance: the partner bank's own documents, which the account owner agrees to before the account can be opened. One code covers the whole outstanding package, and the documents travel with it in `disclosures` — slug, title, version, a link to the PDF, the order to show them in, and the exact wording each acceptance stands for — so you render them without a second call. `disclosures` is `null` on every other code. Acceptance is collected while the owner's application is still open, which is where the onboarding flow already asks for it; once an application is approved its acceptances are frozen, so an owner still owing one after approval needs support. That is why the resolution is `null` rather than a call you could make. The `document.*` codes name files the application must carry rather than values on a form: identity documents for the applicant and for each director or shareholder (front, back, a liveness selfie, and a tax-registration certificate), and the company's own documents (its formation certificate, constitutional document, tax-registration certificate, a board resolution, a beneficial-owner declaration, and an ownership-structure chart). Each is a class code — one `document.person_photo_id` however many people still owe it — and its `resolution` names the upload call, with the stored `documentType` to file it under; for a per-person document, name the person in `personId` on each upload. A document is outstanding until it is confirmed, so follow the resolution through to the confirm step. Which documents an owner owes depends on their business, so treat the set as data: read whichever `document.*` codes come back rather than assuming a fixed checklist. `status` distinguishes the cases where nothing is outstanding: `ready` (the request would succeed), `already_exists` (the owner already holds an account in this currency, and requesting one returns it), and `unavailable` (the currency is not offered to this owner, and no value they supply changes that). The check is read-only and safe to call as often as you like — collecting these from your customer at signup avoids a failed request later. ## Crypto Deposits Open a wallet with `POST /v1/wallets`, then send USDC or USDT to its `address`. The deposit is recorded and credited once the transfer confirms on-chain, and appears with method `crypto_deposit`. `GET /v1/wallets` lists the wallets you hold, newest first with cursor pagination; `GET /v1/wallets/{id}` reads one. > [!WARNING] > > **Live accounts enforce a minimum of 1 USDC or USDT per transfer.** A smaller > transfer is not credited, does not appear in `GET /v1/deposits`, and cannot be > recovered. Contact your account manager if you have sent one. Amounts do not > accumulate: two 0.60 transfers stay uncredited rather than combining to clear > the minimum. > > Transfers funding a [trade-desk order](/guides/orders) are exempt, so a > shortfall top-up of any size still reaches your balance. > > The sandbox enforces no minimum, so sub-unit test transfers are credited > there. ```bash curl theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/wallets" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "network": "eip155:1", "clientRef": "customer-1234" }' ``` ```javascript JavaScript theme={"dark"} const response = await fetch('https://api.sandbox.zuba.com/v1/wallets', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, body: JSON.stringify({ network: 'eip155:1', clientRef: 'customer-1234' }) }); const wallet = await response.json(); // { // id: '9b2f4c1e-...', // network: 'eip155:1', // networkName: 'Ethereum', // address: '0x1234...5678', // assets: ['USDT', 'USDC'], // status: 'active', // clientRef: 'customer-1234', // createdAt: '2026-09-09T12:00:00.000Z' // } ``` `network` names the chain. Pass the CAIP-2 chain identifier or any of the friendly spellings beside it, in any casing; both `POST /v1/wallets` and the `?network=` filter on `GET /v1/wallets` accept either. | Network | `network` value | Also accepted | Assets credited | | - | - | - | - | | Ethereum (ERC-20) | `eip155:1` | `ethereum`, `eth` | USDT, USDC | | Base | `eip155:8453` | `base` | USDC | | Solana (SPL) | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | `solana`, `sol` | USDC, USDT | | Tron (TRC-20) | `tron:mainnet` | `tron`, `trx` | USDT | Every wallet comes back with the CAIP-2 identifier in `network` whichever spelling you sent, and the chain's readable name in `networkName` for display. Key your own logic on `network`. The `network` values listed in the API reference but absent from this table are test networks or networks enabled for individual accounts; a request for one you are not enabled on is refused with `METHOD_NOT_ENTITLED`. Each wallet's `assets` lists the stablecoins your account is credited for on that network. Wallet behaviour: * **One wallet per owner, network and reference.** The first request answers `201` and opens the wallet; every later request for the same network and `clientRef` answers `200` with the same wallet, as long as at least one stablecoin on that network is still enabled for you. If none is, opening is refused with `403 METHOD_NOT_ENTITLED`; read the wallet you already hold with `GET /v1/wallets` instead. Omit `clientRef` for your single default wallet on the network; pass distinct values (for example, your end-customer IDs) to open distinct wallets. A `clientRef` is 1 to 100 single-byte characters with no line break, must not begin or end with whitespace, and must not start with `pool-`. * **A wallet is asset-agnostic**: its address receives every stablecoin in `assets`. A wallet whose `status` is not `active` credits nothing, so its `assets` is empty. * **A wallet credits deposits only on the network it was opened on.** An EVM address is syntactically valid on every EVM chain, but a transfer sent to it on another chain is not credited and cannot be recovered. Open a separate wallet per network. * **Sub-accounts hold their own wallets.** Pass `Zuba-Account-Id` to open, list, or read a sub-account's wallets; deposits to them settle into that sub-account's balance. Without the header you act on, and see, your master account's wallets only. * A `409 Conflict` means the wallet for that owner, network and reference is frozen or archived and cannot be re-opened. Contact support. Crypto deposits are availability-gated per environment. Contact your account manager to enable them. For the crypto payout side, see [Payouts](/concepts/payouts). ## Deposit Lifecycle ```mermaid theme={"dark"} sequenceDiagram participant Customer participant Your App participant Zuba API participant Bank participant Ledger Customer->>Your App: Initiate Deposit Your App->>Zuba API: Create Deposit Zuba API-->>Your App: Virtual Account Details Your App-->>Customer: Show Instructions Customer->>Bank: Transfer Funds Bank->>Zuba API: Receive Transfer Zuba API->>Ledger: Credit Account Your App->>Zuba API: Poll Deposit Status Zuba API-->>Your App: status completed Your App-->>Customer: Deposit Confirmed ``` ## What the Deposit Credits `amount` is what the payer sent. On a deposit that stands, the whole fee you bore is `serviceFee`, and your balance moves by `creditedAmount`: `amount` less that fee, in the deposit's own currency. Where the rail the money arrived on takes its own cut before the funds reach you, that cut is inside `serviceFee` and is never itemised separately. On a deposit into one of your sub-accounts, `serviceFee` also includes any fee you charge on it yourself, which is booked to your master account. Both fields are on every deposit from `GET /v1/deposits` and on the `deposit.completed` webhook, and `serviceFee` is `"0.00000000"` where a credit bore no fee. Both fields are `null` on a deposit that has credited nothing. On one still in flight that is a figure yet to come; on one that failed or was rejected it is the final answer. `status` is what tells the two apart, so read it rather than re-reading the deposit until the figure appears. A deposit that was later returned keeps the figures its credit recorded, so both go on describing the movement that was unwound rather than your position. Read `status` alongside them. Credit your own customer from `creditedAmount`. Reconciling against `amount` over-credits by the fee on every priced deposit. ## Deposit Statuses | Status | Description | | - | - | | `pending` | Deposit created, awaiting funds | | `processing` | Transfer detected, being verified | | `in_review` | Held for compliance review before crediting | | `completed` | Funds received and credited to your balance | | `failed` | Deposit expired or transfer failed | | `cancelled` | Deposit cancelled before completion | | `refunding` | Legacy value, retained for deposits recorded under it. New deposits are not placed in this state | | `refunded` | Funds returned to the sender | ## Checking Deposit Status Deposit IDs are UUIDs. Fetch a deposit by ID to get its current status: ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/deposits/123e4567-e89b-12d3-a456-426614174000" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ```javascript JavaScript theme={"dark"} const status = await fetch(`https://api.sandbox.zuba.com/v1/deposits/${depositId}`, { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } }); const data = await status.json(); console.log(data.status); // pending, processing, completed, etc. ``` > [!NOTE] > > A deposit's terminal outcome is delivered as a webhook: subscribe an endpoint > to `deposit.completed` (funds credited) and `deposit.failed` (the collection > terminally failed, e.g. a declined M-Pesa prompt) — see the > [Webhooks guide](/guides/webhooks#deposit-events). No webhook fires for the > `pending`/`processing` stages; to follow those, poll `GET /v1/deposits/{id}`, > which also triggers an asynchronous status refresh on a `pending` or > `processing` deposit. ## Listing Deposits Retrieve deposits for your account as a paginated list, newest first. Filter with `status`, and page with `limit` (1–100, default 20) plus `cursor`: ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/deposits?status=completed&limit=20" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ```javascript JavaScript theme={"dark"} const page = await fetch('https://api.sandbox.zuba.com/v1/deposits?status=completed&limit=20', { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } }); const { deposits, nextCursor, hasMore } = await page.json(); // deposits: array of deposit objects (id, status, amount, currency, method, clientRef, timestamps) if (hasMore) { const next = await fetch( `https://api.sandbox.zuba.com/v1/deposits?limit=20&cursor=${encodeURIComponent(nextCursor)}`, { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } } ); } ``` Treat `nextCursor` as an opaque token: pass it back verbatim in the `cursor` query parameter to fetch the next page, and stop when `hasMore` is `false`. An invalid `cursor`, `limit`, or `status` value returns a `400` with field-level details. If you manage customer accounts, pass `Zuba-Account-Id: ` to list only that account's deposits; without the header the list covers the whole workspace. Each deposit carries an `accountId` naming the account it was credited to (`null` for your own workspace-level deposits). ## Error Handling Payin endpoints return standard error bodies: ```json 400 Bad Request theme={"dark"} { "statusCode": 400, "message": "Missing required fields for NGN currency: firstName, lastName, email, phoneNumber", "error": "Bad Request" } ``` Common cases: | Status | Message | Cause | | - | - | - | | `400` | `Validation failed` | Malformed request body: for example, a non-decimal `amount` or an unsupported `currency` | | `400` | `Missing required fields for NGN currency: firstName, lastName, email, phoneNumber` | NGN deposit created without the required sender details | | `400` | `Deposit method manual_deposit is not supported for KES` | KES deposit created without `method: "mobile_money"` | | `400` | `M-Pesa STK amount must be a whole KES number greater than zero` | KES M-Pesa `amount` was fractional, zero, or negative | | `400` | `Duplicate request: clientRef already exists` | The `clientRef` was already used. Supply a unique value per deposit | | `400` | (varies) | `GET /v1/deposits/{id}` called with a non-UUID `id` | | `401` | (varies) | Missing or invalid access token | | `404` | (varies) | Deposit not found or does not belong to your account | See the [Error Handling guide](/guides/error-handling) for retry and recovery strategies. ## Best Practices > **Deposit Creation** > > * Always include a unique `clientRef` for idempotency and reconciliation (duplicates are rejected with a `400`) > * Display the full `metadata` block to the sender: account number, account name, bank name, and expiry > * Communicate the virtual account expiry (`metadata.expiresAt`) so the sender transfers in time > * Store deposit IDs (UUIDs) for status tracking and customer support > **Status Monitoring** > > * Poll `GET /v1/deposits/{id}` with exponential backoff > * Handle all eight statuses, including `in_review` and `refunded` > * Treat `in_review` as not-yet-credited: funds are held for compliance review > * Log deposit events for audit and debugging > **Security** > > * Use HTTPS for all API communications > * Never log sensitive customer payment data > * Implement proper access controls for deposit endpoints > * If you consume webhooks for other events, verify signatures (see [Verifying Signatures](/guides/webhooks#verifying-signatures)) ## Next Steps > **[Payouts](/concepts/payouts)** > > Send money globally to beneficiaries > **[Webhook Setup](/guides/webhooks)** > > Real-time notifications for payout, order, and account events > **[Ledger System](/concepts/ledger)** > > Track transactions with double-entry accounting > **[API Reference](/api-reference/introduction)** > > Complete API documentation # Payouts Source: https://docs.zuba.com/concepts/payouts Send money globally with Zuba payouts ## Overview Payouts are the core functionality of the Zuba platform, enabling you to send money to beneficiaries worldwide through various payment rails including international transfers, SWIFT, US domestic rails (ACH and Fedwire), crypto networks, local bank transfers, and mobile money. ## Payout Lifecycle ```mermaid theme={"dark"} sequenceDiagram participant Client participant Zuba API participant Provider participant Bank/Wallet Client->>Zuba API: Create Payout Zuba API->>Zuba API: Validate & Record Zuba API-->>Client: Payout ID Zuba API->>Provider: Process Payment Provider->>Bank/Wallet: Transfer Funds Bank/Wallet-->>Provider: Confirmation Provider-->>Zuba API: Status Update Zuba API-->>Client: Webhook Notification ``` ## Beneficiaries & Accounts ### Beneficiary Management Before sending payouts, you must create beneficiaries with their payment details: ```javascript Get Beneficiary theme={"dark"} const beneficiary = await fetch(`https://api.zuba.com/v1/beneficiaries/${beneficiaryId}`, { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); ``` ```javascript Create Beneficiary theme={"dark"} const beneficiary = await fetch('https://api.zuba.com/v1/beneficiaries', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'John Doe', email: 'john.doe@example.com', country: 'US', countrySubdivision: 'NY', address: '123 Main St', city: 'New York', postcode: '10001', accounts: [{ type: 'bank_account', currency: 'USD', data: { accountNumber: '1234567890', routingNumber: '021000021', accountHolderName: 'John Doe', bankName: 'Chase Bank' } }] }) }); ``` ### Account Types Zuba supports multiple account types for maximum flexibility: **Bank Accounts** > [!NOTE] > > For Nigeria, Ghana, Kenya, Uganda, Cameroon, Côte d'Ivoire and South > Africa, `bankCode` is Zuba's internal bank identifier. Fetch the > supported banks and their codes for a country from > `GET /v1/banks?country=` (e.g. `?country=CM`), which > returns `[{ "name", "bankCode" }]` sorted by name. For Kenya and Uganda > the list is the payable subset rather than every registered bank. For > South Africa and Nigeria it is every bank in Zuba's registry; paying a > South African bank requires ZAR payouts to be enabled for your account. > For Nigeria, the default when `country` is omitted, each bank also > carries `shortCode`, `nipCode` and, where it has any, `alternativeCodes`, > and a beneficiary account accepts every one of those forms (see the > Nigerian bank account below). **EUR (IBAN)** `bic` is optional on `iban` accounts. ```json theme={"dark"} { "type": "iban", "currency": "EUR", "data": { "iban": "DE89370400440532013000", "accountHolderName": "John Doe", "bic": "DEUTDEFF" } } ``` **GBP (IBAN)** GBP beneficiaries also use the `iban` account type, with a GB IBAN: ```json theme={"dark"} { "type": "iban", "currency": "GBP", "data": { "iban": "GB29NWBK60161331926819", "accountHolderName": "John Smith", "bic": "NWBKGB2L" } } ``` **US Bank Account (USD: ACH / Domestic Wire)** ```json theme={"dark"} { "type": "bank_account", "currency": "USD", "data": { "accountNumber": "1234567890", "routingNumber": "021000021", "accountHolderName": "John Doe", "bankName": "Chase Bank" } } ``` **Nigerian Bank Account** `bankCode` is Zuba's internal Nigeria bank identifier (e.g. `ng_0001` for Access Bank), the `bankCode` that `GET /v1/banks` returns; `crAccount` is the 10-digit account number. ```json theme={"dark"} { "type": "bank_account", "currency": "NGN", "data": { "bankCode": "ng_0001", "crAccount": "1234567890" } } ``` The 3-digit CBN short code (e.g. `044`) and the 6-digit NIP long code (e.g. `000014`) stay accepted, so an integration that sends them keeps working, and an account stores and returns `bankCode` exactly as it was sent. A saved account's Zuba code is its `zubaBankCode` (below); the `shortCode` (the bank's short code in Zuba's registry: its CBN code for most banks, a lettered or longer code for a few), `nipCode` and, where a bank has any, `alternativeCodes` fields that each Nigerian entry of `GET /v1/banks` carries show which other codes name the same bank. A code outside those four forms is accepted when Zuba's registry still recognises it (a zero-padded variant such as `013`, for instance) and refused otherwise. ```json theme={"dark"} { "name": "Access Bank", "bankCode": "ng_0001", "shortCode": "044", "nipCode": "000014", "alternativeCodes": ["223"] } ``` A payout or beneficiary response returns a bank account's `data` as stored, plus `zubaBankCode`: the Zuba code of its bank. That equals `bankCode` when the account was saved under the Zuba code; an account saved under an older form (a Nigerian CBN or NIP code, or the short code a Ghanaian, Cameroonian or Ivorian account may carry) has it resolved from that form. A payout whose account matches one already saved on the beneficiary is paid to that saved account, so its `bankCode` is the code the account was saved under, which may differ from the code in the request. Compare bank identity on `zubaBankCode`, which is the same whichever code form either side used. It is computed on every response and is not stored, and it is absent when Zuba resolves the stored code to no bank. ```json theme={"dark"} "payoutAccountData": { "bankCode": "000013", "crAccount": "0123456789", "zubaBankCode": "ng_0008" } ``` **Ghanaian Bank Account** `bankCode` is Zuba's internal Ghana bank identifier (e.g. `gh_0001`); `crAccount` is the 8–20 digit account number. ```json theme={"dark"} { "type": "bank_account", "currency": "GHS", "data": { "bankCode": "gh_0002", "crAccount": "12223444555" } } ``` **Kenyan Bank Account** `bankCode` is Zuba's internal Kenya bank identifier (e.g. `ke_0001` for KCB, `ke_0003` for Absa); `crAccount` is the 5–20 digit account number. KES is **dual-rail** — the same currency also pays out to a mobile money wallet (see the **Mobile Money** tab). ```json theme={"dark"} { "type": "bank_account", "currency": "KES", "data": { "bankCode": "ke_0001", "crAccount": "1000012345" } } ``` **South African Bank Account** `bankCode` is Zuba's internal South Africa bank identifier (e.g. `za_0001` for Standard Bank, `za_0002` for FNB / RMB); `crAccount` is the 5–20 digit account number. `accountType` is optional: `current`, `savings` or `transmission`, defaulting to `current` when omitted. Send it when you know it, because the receiving bank validates the account number against its type. A **business** beneficiary paid to a South African bank account must also carry its company `registrationNumber`. A ZAR `amount` carries at most two decimal places; a finer amount is rejected at creation. ZAR payouts are enabled per account: until a ZAR rate is set up for yours, a ZAR payout is refused with `CORRIDOR_NOT_ENABLED`. Contact your account manager to enable them. ```json theme={"dark"} { "type": "bank_account", "currency": "ZAR", "data": { "bankCode": "za_0002", "crAccount": "62000000000", "accountType": "current" } } ``` **Cameroonian Bank Account** `bankCode` is Zuba's internal Cameroon bank identifier (e.g. `cm_0001` for Afriland First Bank, `cm_0006` for BGFIBANK); `crAccount` is the 8–24 digit account number. XAF is **dual-rail** — the same currency also pays out to a mobile money wallet (see the **Mobile Money** tab). ```json theme={"dark"} { "type": "bank_account", "currency": "XAF", "data": { "bankCode": "cm_0006", "crAccount": "10005000123456" } } ``` **XAF payouts require the sender's KYC on both rails.** An **individual** sender must supply, in `senderInfo`: `firstName` and `lastName`, `phoneNumber` in international format, `fundOrigin` (`SALARY`, `BUSINESS`, or `INVESTMENT`), `gender` (`M` or `F`), and `country` (the sender's own country as an ISO 3166-1 alpha-2 code), or the payout is rejected at creation. The same set applies to XAF mobile money — see the **Mobile Money** tab. To send as your own account, omit `senderInfo` entirely — the originator is then the paying account as its KYB application was approved (your workspace, or the sub-account named in `Zuba-Account-Id`). An account approved as an individual pays as that person, so on XAF it cannot omit the block: it supplies the individual set above. Supply `senderInfo` otherwise only to pay out on behalf of a distinct third party; a **business** third-party sender must include `companyName`, `registrationNumber`, and `country` (its jurisdiction of incorporation). ```javascript XAF Bank Payout (Cameroon) theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-BANK-XAF-001', amount: '50000', currency: 'XAF', route: 'bank_transfer', beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, senderInfo: { type: 'individual', firstName: 'Jane', lastName: 'Doe', phoneNumber: '+237655555555', fundOrigin: 'SALARY', gender: 'F', country: 'CM' }, reference: 'Supplier payment' }) }); ``` **Ivorian Bank Account** `bankCode` is Zuba's internal Côte d'Ivoire bank identifier (e.g. `ci_0009` for Ecobank, `ci_0013` for Société Générale); `crAccount` is the 8–24 digit account number. XOF is **dual-rail** — the same currency also pays out to a mobile money wallet (see the **Mobile Money** tab), and the bank rail serves Côte d'Ivoire only. ```json theme={"dark"} { "type": "bank_account", "currency": "XOF", "data": { "bankCode": "ci_0013", "crAccount": "000100123456789" } } ``` The per-transaction amount is **XOF 200–2,000,000**. **Côte d'Ivoire bank payouts use the sender's name.** Supply `firstName` and `lastName` in `senderInfo` for an individual sender (or `companyName` for a business sender). If `senderInfo` is omitted, the paying account's approved name is used as the originator instead. Unlike Cameroon, Côte d'Ivoire does not need `phoneNumber`, `fundOrigin`, or `gender`. **Ugandan Bank Account** `bankCode` is Zuba's internal Uganda bank identifier (e.g. `ug_0020` for Stanbic, `ug_0002` for Absa); `crAccount` is the 5–20 digit account number. UGX is **dual-rail** — the same currency also pays out to a mobile money wallet (see the **Mobile Money** tab). The Ugandan shilling has no minor unit, so `amount` must be a whole number on both rails. ```json theme={"dark"} { "type": "bank_account", "currency": "UGX", "data": { "bankCode": "ug_0020", "crAccount": "1002003004" } } ``` The per-transaction minimum is **UGX 1**, with no published ceiling. > [!NOTE] > > UGX **bank** payouts are in early access — a gated corridor. An unpinned > workspace's UGX bank payout is rejected at creation with `RAIL_UNAVAILABLE`. > UGX **mobile money** is gated on the same corridor. Enablement can be > granted per rail, so ask your account manager for the rails you need. **Argentine Bank Account (ARS)** > [!NOTE] > > **ARS payouts are in beta** — a gated corridor. Contact your account > manager to have it enabled for your workspace; until it is, a payout to > ARS is rejected at creation. An Argentine beneficiary is identified by a single account string plus the kind of identifier it is. `accountNumber`, `accountType` and `accountHolderName` are all required. `accountType` is not inferred: a CBU and a CVU are both 22 digits and cannot be told apart by shape. | `accountType` | What it is | Format | | :- | :- | :- | | `CBU` | Clave Bancaria Uniforme — a bank account at a traditional bank | 22 digits | | `CVU` | Clave Virtual Uniforme — an account at a digital wallet or fintech | 22 digits | | `ALIAS` | A human-readable alias registered against a CBU or CVU | Alphanumeric, dot-separated | ```json theme={"dark"} { "type": "bank_account", "currency": "ARS", "data": { "accountNumber": "0290013110000576749489", "accountType": "CBU", "accountHolderName": "María González" } } ``` **ARS payouts additionally require the sender's identity.** Supply, in `senderInfo`: `firstName`, `lastName`, and `dateOfBirth` (`YYYY-MM-DD`). This is required whether you are paying as your own workspace or on behalf of a third party. ```javascript ARS Bank Payout (Argentina) theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-BANK-ARS-001', amount: '150000.00', currency: 'ARS', route: 'bank_transfer', beneficiary: { id: 'f1b9c2a4-8e7d-4a3b-9c15-2d6e8f0a4b71' }, senderInfo: { type: 'individual', firstName: 'Jane', lastName: 'Doe', dateOfBirth: '1990-01-01' }, reference: 'Supplier payment' }) }); ``` **Sandbox-only currencies** > [!NOTE] > > **ZMW** (Zambian kwacha), **MWK** (Malawian kwacha) and **EGP** (Egyptian > pound) are available in the **sandbox only**. Payouts to them settle with > simulated outcomes. See > [Sandbox testing](/guides/sandbox-testing#sandbox-only-payout-currencies). > In production these currencies are not yet available and a payout to one > is rejected at creation. > > **MZN** (Mozambican metical) is simulated in the sandbox too, over mobile > money rather than a bank account. See > [Mozambique (MZN) mobile money](/guides/sandbox-testing#mozambique-mzn-mobile-money). > In production an MZN payout is rejected at creation unless your workspace > has been enabled for it. ```json Zambian Bank Account (sandbox only) theme={"dark"} { "type": "bank_account", "currency": "ZMW", "data": { "bankCode": "260001", "accountNumber": "1234567890", "accountHolderName": "Chanda Mwansa" } } ``` The same shape applies for `MWK` and `EGP`: any `bankCode` is accepted in the sandbox. **Crypto Wallets** Stablecoin payouts support **USDC** and **USDT** on **Ethereum** (ERC-20) and **Solana** (SPL), and **USDT** on **Tron** (TRC-20). USDC is not available on Tron. `accountHolderName` is required on wallet accounts. **Individual beneficiaries need a date of birth.** In production a crypto payout to an `individual` beneficiary is screened under the Travel Rule, whatever the amount, and the beneficiary must carry `dateOfBirth` (`YYYY-MM-DD`). `type` defaults to `individual`, so a beneficiary created without one is screened as an individual. Set the date of birth when you create the beneficiary, or add it to a saved one with `PUT /v1/beneficiaries/{id}`. A `business` beneficiary needs no date of birth. See [Travel Rule](#travel-rule) for what is checked when. **Individual beneficiary with a USDC wallet on Solana** ```json theme={"dark"} { "name": "John Doe", "type": "individual", "country": "US", "dateOfBirth": "1985-05-20", "accounts": [{ "type": "wallet", "currency": "USDC", "data": { "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "network": "SOLANA", "accountHolderName": "John Doe" } }] } ``` **USDT on Tron (TRC-20)** ```json theme={"dark"} { "type": "wallet", "currency": "USDT", "data": { "address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "network": "TRON", "accountHolderName": "John Doe" } } ``` **USDC on Solana (SPL)** ```json theme={"dark"} { "type": "wallet", "currency": "USDC", "data": { "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "network": "SOLANA", "accountHolderName": "John Doe" } } ``` **USDT on Solana (SPL)** ```json theme={"dark"} { "type": "wallet", "currency": "USDT", "data": { "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "network": "SOLANA", "accountHolderName": "John Doe" } } ``` **USDC on Ethereum (ERC-20)** ```json theme={"dark"} { "type": "wallet", "currency": "USDC", "data": { "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", "network": "ETHEREUM", "accountHolderName": "John Doe" } } ``` **USDT on Ethereum (ERC-20)** ```json theme={"dark"} { "type": "wallet", "currency": "USDT", "data": { "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", "network": "ETHEREUM", "accountHolderName": "John Doe" } } ``` **Mobile Money** Mobile money pays out to a beneficiary's mobile money wallet in **XOF** (West African CFA franc), **XAF** (Central African CFA franc), **GHS** (Ghana cedi), **KES** (Kenyan shilling), or **UGX** (Ugandan shilling). > [!NOTE] > > **UGX** mobile money is in early access, gated on the same corridor as the > UGX bank rail; an unpinned workspace is rejected at creation with > `RAIL_UNAVAILABLE`. Enablement can be granted per rail, so ask your account > manager for the rails you need. XOF, XAF, GHS and KES mobile money are > open. ```json XOF (West Africa) theme={"dark"} { "type": "mobile", "currency": "XOF", "data": { "mobileProvider": "orange", "phoneNumber": "+2250123456789", "country": "CI" } } ``` ```json XAF (Cameroon) theme={"dark"} { "type": "mobile", "currency": "XAF", "data": { "mobileProvider": "mtn", "phoneNumber": "+237650000000", "country": "CM" } } ``` ```json GHS (Ghana) theme={"dark"} { "type": "mobile", "currency": "GHS", "data": { "mobileProvider": "mtn", "phoneNumber": "+233241234567", "country": "GH" } } ``` ```json KES (Kenya) theme={"dark"} { "type": "mobile", "currency": "KES", "data": { "mobileProvider": "safaricom", "phoneNumber": "+254712345678", "country": "KE" } } ``` ```json UGX (Uganda) theme={"dark"} { "type": "mobile", "currency": "UGX", "data": { "mobileProvider": "mtn", "phoneNumber": "+256772123456", "country": "UG" } } ``` `phoneNumber` must be in international format (including the `+`). `mobileProvider` must be valid for `country`: | Country | `currency` | `country` | `mobileProvider` | | - | - | - | - | | Côte d'Ivoire | `XOF` | `CI` | `orange`, `mtn`, `moov`, `wave` | | Senegal | `XOF` | `SN` | `orange`, `free`, `wave` | | Mali | `XOF` | `ML` | `orange`, `moov`, `wave` | | Burkina Faso | `XOF` | `BF` | `orange`, `moov`, `wave` | | Benin | `XOF` | `BJ` | `mtn`, `moov` | | Togo | `XOF` | `TG` | `moov`, `tmoney` | | Cameroon | `XAF` | `CM` | `mtn`, `orange` | | Ghana | `GHS` | `GH` | `mtn`, `vodafone`, `airteltigo` | | Kenya | `KES` | `KE` | `safaricom` (or `mpesa`), `airtel`, `equitel` | | Uganda | `UGX` | `UG` | `mtn` (or `momo`), `airtel` | Pay out with `route: "mobile_money"`. See [Mobile money](#mobile-money-xof-xaf-ghs-kes-and-ugx) below for amount limits, sender requirements, and the dual-rail currencies (GHS, KES, UGX). ## Creating Payouts ### Single Payout After creating a beneficiary, use their ID to create payouts (recommended approach). **Currency Fields:** `inputCurrency` is the currency from your account you'll be paying from, while `currency` is what the beneficiary will receive (automatic conversion if different): ```javascript Single Payout (Recommended) theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-INV-001', amount: '1000.00', inputCurrency: 'EUR', currency: 'NGN', route: 'bank_transfer', senderInfo: { firstName: 'Jack', lastName: 'Jones', address: '456 London Road', city: 'London', postalCode: 'SW1A 1AA', country: 'GB', dateOfBirth: '1985-06-15' }, beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, reference: 'Invoice #INV-001', description: 'Marketing services' }) }); ``` > [!NOTE] > > `senderInfo` is optional. When omitted, the originator is the paying account > as its KYB application was approved: your workspace, or the sub-account named > in `Zuba-Account-Id` (see **Sending from a sub-account**). An account approved > as a business is named by its legal name, and one approved as an individual > by that person. Some corridors require `senderInfo`: GHS mobile money needs > the > sender's first and last name, and XAF mobile money needs the sender's name > and phone number. In production a crypto payout's individual sender must > also carry `dateOfBirth`; see [Travel Rule](#travel-rule). ### When a quote is required A quote (`POST /v1/quotes`, passed as `quoteId`) is **required** only for payouts funded from an NGN balance into USD, EUR or GBP. On every other pair a `quoteId` is optional: pass one to lock the rate for the payout, or omit it and the payout is priced at the current rate. A payout quote fixes one side of the pair. Send `toAmount` to fix what the beneficiary receives, or `fromAmount` to fix what you are debited before the payout fee; the quote returns the other side. Quotes from an NGN balance into USD, EUR or GBP take `toAmount` only. Create the payout with `amount` equal to the quote's `targetAmount`, which a `fromAmount` quote on a cross-currency pair rounds down to the smallest unit every payout method in that currency accepts: whole units for currencies with no minor unit, such as XOF, XAF and UGX, and whole shillings for KES. On a same-currency pair `targetAmount` equals `fromAmount`. The payout fee is not part of the quote: it is added to the debit when the payout is created, whichever side you fixed. A cross-currency payout on a pair listed by `GET /v1/fx/cross-rate-quotes/corridors` goes out only where your pricing policy covers it; a listed pair nothing prices is rejected with `CORRIDOR_NOT_ENABLED`, with or without a quote. On such a pair the amount must also sit inside that corridor's per-transaction band, or the payout is rejected with `AMOUNT_OUT_OF_BOUNDS`. Same-currency payouts carry no band on the payout path. Sends into NGN require your policy to price the pair, with or without a quote, whichever currency funds them. A cross-currency pair the listing has never carried is priced at the current rate whether or not your policy prices it, except a CHF payout. A CHF payout is paid only from an XOF balance to a Swiss IBAN: from any other balance it is rejected with `CORRIDOR_NOT_ENABLED`, to an IBAN from another country with `IBAN_COUNTRY_NOT_SUPPORTED`, and on the `swift` or `crypto` route with `RAIL_UNAVAILABLE`. No published `route` value sends a CHF payout yet; CHF is enabled per client on request. A pair the listing carries for other clients but not for you is one your policy does not cover, and is refused. A pair whose stored rate is zero or negative is rejected with `INSUFFICIENT_LIQUIDITY`; a pair with no stored rate at all, or whose rate has expired, is rejected with a `400` carrying the message `FX rate not available for to conversion` and no error code. When your source balance is held in an account of your own that cannot fund the payout, the payout is refused before anything is debited. With a `quoteId` it is rejected with `422` and one of these codes: `FUNDING_ACCOUNT_NOT_ACTIVE` (the account is frozen), `FUNDING_ACCOUNT_AMBIGUOUS` (your balance in the source currency is held in more than one place; contact support to resolve it), or `FUNDING_ACCOUNT_UNAVAILABLE` (the account cannot be reached; try again later). The quote stays unused, so you can send the payout again with it while it is valid. Without a `quoteId`, the same causes return `201` with the payout `failed` and its `failureReason` naming the cause. A CAD payout is paid only from a EUR or USD balance, to a Canadian bank account: a `bank_account` in `CAD` whose `data` carries `accountNumber` (5–12 digits), `institutionNumber` (3 digits), `transitNumber` (5 digits) and `accountHolderName`, with an optional `bankName`, and the bank's `bic` (8 or 11 characters, with `CA` as characters 5 and 6). The `bic` may be left out for Bank of Montreal (001), Scotiabank (002), Royal Bank of Canada (003), TD Canada Trust (004), National Bank of Canada (006), CIBC (010), Laurentian Bank (039), ATB Financial (219) and Desjardins (815), whose BIC is filled in. Spaces and dashes in the numbers are accepted and removed. The recipient needs a full address (address, city and postcode); a province in `countrySubdivision` is sent when given. From any other balance a CAD payout is rejected with `CORRIDOR_NOT_ENABLED`; a missing number with `MISSING_BANK_ACCOUNT_DETAILS`, and a malformed one with `INVALID_ACCOUNT_NUMBER` or `INVALID_ROUTING_NUMBER` on its own field; a missing `bic` with `MISSING_BIC`, and a malformed one with `INVALID_BIC`. Each payout is between 1 and 250,000 CAD. No published `route` value sends a CAD payout yet; CAD is enabled per client on request, with the route to send. A CNY payout is paid only from a EUR or USD balance, to an Alipay wallet in China: a `mobile` account in `CNY` whose `data` carries `mobileProvider` `alipay`, the wallet's Chinese mobile number in `phoneNumber` (international format, with the `+86`) and `country` `CN`. The recipient is an individual whose `name` is a first and a last name in Latin letters (pinyin for a Chinese name), up to 35 characters each; no address is needed. The sender must be a business, named in `senderInfo` with its `registrationNumber`. A `purpose` is required and must name one of GoodsAndServices, Business, Salary, Interpretation, Translation, HumanResource, EstateAgency, SoftwareDevelopment, WebDesignOrDevelopment, DraftingLegal, LegalCertification, Accounting, ArchitecturalDecorationDesign, Advertising, MarketResearch, ExhibitionBooth, ProductPromotion or EcommercePromotion (case, spaces and punctuation ignored). A beneficiary referenced by `id` must hold exactly one active CNY wallet. From any other balance a CNY payout is rejected with `CORRIDOR_NOT_ENABLED`; a business recipient with `UNSUPPORTED_BENEFICIARY_TYPE`, a name not in Latin letters with `INVALID_BENEFICIARY_NAME`, an individual sender or one without a `registrationNumber` with `SENDER_INFO_INCOMPLETE`, a missing `purpose` with `MISSING_PURPOSE` and one outside the list with `UNSUPPORTED_PURPOSE`, and a beneficiary referenced by `id` that holds more than one active CNY wallet with `MULTIPLE_ACTIVE_ACCOUNTS`. Each payout is between 10 and 50,000 CNY. No published `route` value sends a CNY payout yet; CNY is enabled per client on request, with the route to send. > [!WARNING] > > Sending without a `quoteId` removes the quote's single-use protection: one quote can fund at most one payout, so a retried request carrying the same `quoteId` cannot pay twice. On a payout sent without one, that guard is the `Idempotency-Key` header. Send an `Idempotency-Key` on any request you might retry, including a retry after a timeout where you never saw the response. ### Batch Payouts Process multiple payouts in a single API call. Every payout in the batch is validated independently and requires its own `route`: ```javascript Batch Payouts theme={"dark"} const payouts = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify([ { clientRef: 'PAYOUT-BATCH-001', beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, amount: '1000.00', inputCurrency: 'EUR', currency: 'NGN', route: 'bank_transfer', senderInfo: { firstName: 'Jack', lastName: 'Jones', address: '456 London Road', city: 'London', postalCode: 'SW1A 1AA', country: 'GB', dateOfBirth: '1985-06-15' }, reference: 'Salary March 2024' }, { clientRef: 'PAYOUT-BATCH-002', beneficiary: { id: '123e4567-e89b-12d3-a456-426614174000' }, amount: '1500.00', inputCurrency: 'EUR', currency: 'USD', route: 'ach', reference: 'Freelancer payment' } ]) }); ``` ### Sending from a sub-account If your workspace has sub-accounts, pass the sub-account's ID in the optional `Zuba-Account-Id` header on `POST /v1/payouts` to send the payout **from that sub-account's balance**: the amount and fee are debited from the sub-account, and the payout records which sub-account it belongs to. Omit the header to send from your master (workspace) balance, exactly as the examples above. A sub-account payout that omits `senderInfo` names the sub-account as the originator, as its own KYB application was approved: a business by its legal name and registration number, an individual by their name. The name is what the payment provider receives; the registration number and date of birth go to screening and the Travel Rule. It is never your master workspace's identity. A payout that omits `senderInfo` is rejected at creation with `ORIGINATOR_NOT_ON_RECORD` when the paying account, master or sub-account, has no originator of record: * it holds no approved KYB application; * an account approved as a business holds no legal name on its application; * a sub-account approved as a business holds no registration number on its application; * an account approved as an individual holds no first or last name on its application; * on a crypto payout, an account approved as an individual holds no date of birth on its application. Supply `senderInfo` to name a third party, exactly as from the master. With `Zuba-Account-Id` set, these calls reach only that sub-account's own records: * `POST /v1/payouts` can name a saved beneficiary by `id` only when the beneficiary was saved under the same sub-account, and can spend a `quoteId` only when the quote was created with the same header on `POST /v1/quotes`. * `GET /v1/payouts` and `GET /v2/payouts` list only that sub-account's payouts. * `GET /v1/beneficiaries`, `GET /v2/beneficiaries`, `GET /v1/beneficiaries/{id}`, `PUT /v1/beneficiaries/{id}`, `DELETE /v1/beneficiaries/{id}`, `POST /v1/beneficiaries/{id}/accounts`, and `PATCH` and `DELETE` on `/v1/beneficiaries/{id}/accounts/{accountId}` reach only beneficiaries saved under that sub-account. `POST /v1/beneficiaries` saves the beneficiary under it. * `GET /v1/payouts/{id}`, `POST /v1/payouts/{id}/cancel`, `GET /v1/payouts/{id}/receipt` and both `POST` and `GET` on `/v1/payouts/{id}/tracking-link` act only on that sub-account's payouts. * In the sandbox, `POST /v1/sandbox/payouts/{id}/release` and `POST /v1/sandbox/payouts/{id}/decline` act only on that sub-account's payouts. * `GET /v1/quotes/{id}` returns a quote of any intent only when it was created with the same header. * `POST /v1/conversions` and `POST /v1/orders` can spend a quote only when it was created with the same header, and `POST /v1/orders` can name a saved beneficiary only when it was saved under the same sub-account. A conversion made with the header debits and credits that sub-account's own balance, never your master workspace's. * `GET /v1/conversions` and `GET /v1/conversions/{id}` return only conversions made with the same header. * `GET /v1/orders`, `GET /v1/orders/{id}`, `POST /v1/orders/{id}/cancel`, `POST /v1/orders/{id}/settlement/proof-upload-url` and `POST /v1/orders/{id}/settlement/proof` act only on orders created with the same header. Any other record, including one belonging to your master workspace, gets the same answer as an id that does not exist. A call without the header acts for the whole workspace and reaches the records of every sub-account. A header naming your master workspace's own account id acts as no header. The named sub-account must be one you own: a header naming an account you do not own is rejected with `403 not_account_owner`. On every call that moves money or issues an account to pay into (`POST /v1/payouts`, `POST /v1/conversions`, `POST /v1/orders`, `POST /v1/deposits`, `POST /v1/wallets` and `POST /v1/virtual-accounts`) the sub-account must also be **active**, and one that is not yet active is rejected with `422 ACCOUNT_NOT_ACTIVE`. Reads are never refused for this. Sub-accounts are a gated feature — contact your account manager to enable them. ## Payment Routes Select the rail with the `route` field on each payout request: | Route | Regions | Speed | | - | - | - | | `bank_transfer` | Local bank rails: Nigeria (NGN), Ghana (GHS), Kenya (KES), Uganda (UGX), Cameroon (XAF), Côte d'Ivoire (XOF), Argentina (ARS — beta, see **Account Types**) | Minutes to 1 business day | | `ach` | United States | 1-3 business days | | `fedwire` | United States (domestic wire) | Same business day | | `swift` | International (availability varies by destination country) | 1-3 business days | | `crypto` | On-chain (Ethereum, Solana, Tron) | Minutes (network-dependent) | | `mobile_money` | West Africa (XOF: CI, SN, ML, BF, BJ, TG), Central Africa (XAF: Cameroon), Ghana (GHS), Kenya (KES), and Uganda (UGX) | Minutes | > [!NOTE] > > **EUR and GBP** payouts are delivered to IBAN accounts over Zuba's > international transfer network. The rail is selected automatically from the > beneficiary's `iban` account and surfaces as `international` on payout reads > and in the dashboard. The former `sepa_credit` / `sepa_inst` route values > have been retired and are no longer routable. ### Crypto rail Set `route: "crypto"` to send a stablecoin (`USDC` or `USDT`) on-chain to a crypto wallet beneficiary. The `currency` is the stablecoin and is debited from your same-currency balance (no FX). The destination network and address come from the beneficiary's `wallet` account (see **Crypto Wallets** above), so the request only references the beneficiary by `id`: ```javascript Crypto Payout theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-CRYPTO-001', amount: '250.00', currency: 'USDC', route: 'crypto', beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, reference: 'Contractor payment' }) }); ``` The funds are held the moment the payout is created, screened for compliance, then signed and broadcast on-chain. The payout reaches `paid` only once the transaction has broadcast; if it is declined or signing fails, the held funds are returned to your balance automatically. Crypto payouts are availability-gated per environment. Contact your account manager to enable them. Once the transfer is broadcast, `GET /v1/payouts/{id}` returns the on-chain transaction hash in the `txHash` field (`null` for non-crypto payouts or before broadcast). Use it to look the transfer up on a block explorer for the payout's network. #### Travel Rule In production every crypto payout is screened under the Travel Rule, whatever the amount, so both parties must be identifiable. Omit `senderInfo` and the originator is the paying account as its KYB application was approved (your workspace, or the sub-account named in `Zuba-Account-Id`). An account approved as a business needs no date of birth. One approved as an individual must hold a date of birth on its application, or the payout is rejected at creation with `ORIGINATOR_NOT_ON_RECORD`, as is any account with no originator of record (see [Sending from a sub-account](#sending-from-a-sub-account)). Otherwise: | Party | Required | Checked | | - | - | - | | `individual` beneficiary (the default `type`) | `dateOfBirth` on the beneficiary | At `POST /v1/payouts`: refused with `400`, no payout is created and no funds are held | | Individual sender (`senderInfo` with `type: "individual"`, the default) | `firstName`, `lastName` and `dateOfBirth` in `senderInfo` | After creation: the payout is created and its funds held, then it fails, the hold is returned and `payout.failed` fires | | `business` beneficiary or business sender | No date of birth (a `dateOfBirth` on a business sender is refused with `SENDER_INFO_INVALID`) | — | The beneficiary refusal is a `400` whose `message` names `dateOfBirth` and carries no `code`. Add the date of birth with `PUT /v1/beneficiaries/{id}` and resubmit. The sandbox does not ask for a date of birth on an individual beneficiary or an individual sender, so a crypto payout that succeeds there without one is refused in production. The rule above for an account approved as an individual applies in the sandbox too. ### Mobile money (XOF, XAF, GHS, KES, and UGX) Set `route: "mobile_money"` to pay out **XOF** to a beneficiary's mobile money wallet across West Africa, **XAF** to a wallet in Cameroon, **GHS** to a wallet in Ghana, **KES** to a wallet in Kenya, or **UGX** to a wallet in Uganda. The destination provider and phone number come from the beneficiary's `mobile` account (see **Mobile Money** under Account Types above): ```javascript Mobile Money Payout theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-MM-001', amount: '5000', currency: 'XOF', route: 'mobile_money', beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, reference: 'Supplier payment' }) }); ``` Supported in **Côte d'Ivoire, Senegal, Mali, Burkina Faso, Benin, and Togo** (XOF), **Cameroon** (XAF), **Ghana** (GHS), **Kenya** (KES), and **Uganda** (UGX). The per-transaction amount depends on the destination currency: **XOF 200–2,000,000**, **XAF 500–1,000,000**, **GHS 5–50,000**, **KES 1–250,000**, **UGX 1–5,000,000**. A mismatched provider/country, an invalid phone number, an unsupported country, a currency that isn't `XOF`/`XAF`/`GHS`/`KES`/`UGX`, or an out-of-range amount is rejected at creation. **UGX amounts must be whole numbers.** The Ugandan shilling has no minor unit, so a fractional `amount` is rejected at creation on both the mobile and bank rails. **Ghana (GHS), Kenya (KES), Uganda (UGX), Cameroon (XAF), and Côte d'Ivoire (XOF) are dual-rail.** The same currency also pays out over a bank account (`route: "bank_transfer"`); the rail is chosen by the beneficiary's account type (`mobile` vs `bank_account`). XOF mobile money covers six West African countries but the XOF bank rail serves Côte d'Ivoire only. XAF payouts carry extra sender KYC on both rails (`fundOrigin`, `gender`, and `country`) — see the **Bank Account** tab. **GHS mobile requires the sender's first and last name** via `senderInfo` (no phone, unlike Cameroon), or the payout is rejected: ```javascript GHS Mobile Money Payout (Ghana) theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-MM-GHS-001', amount: '100', currency: 'GHS', route: 'mobile_money', beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, senderInfo: { type: 'individual', firstName: 'Ama', lastName: 'Owusu' }, reference: 'Supplier payment' }) }); ``` **Kenya (KES) and Uganda (UGX) need no `senderInfo`.** Uganda is in early access on either rail — see the gate note under **Mobile Money** in **Beneficiary account types**. Supply the beneficiary and amount only: ```javascript KES Mobile Money Payout (Kenya) theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-MM-KES-001', amount: '5000', currency: 'KES', route: 'mobile_money', beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, reference: 'Supplier payment' }) }); ``` The same shape applies to Uganda, with a whole-number `amount`: ```javascript UGX Mobile Money Payout (Uganda) theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-MM-UGX-001', amount: '10000', currency: 'UGX', route: 'mobile_money', beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, reference: 'Supplier payment' }) }); ``` **Cameroon (XAF) additionally requires the sender's KYC.** An individual sender must supply `firstName`, `lastName`, `phoneNumber`, `fundOrigin`, `gender`, and `country` in `senderInfo` — the same set the bank rail requires — or the payout is rejected at creation: ```javascript XAF Mobile Money Payout (Cameroon) theme={"dark"} const payout = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-MM-XAF-001', amount: '2000', currency: 'XAF', route: 'mobile_money', beneficiary: { id: 'cc82fa1d-fc7a-478c-a734-d3bce40464e7' }, senderInfo: { type: 'individual', firstName: 'Jane', lastName: 'Doe', phoneNumber: '+237655555555', fundOrigin: 'SALARY', gender: 'F', country: 'CM' }, reference: 'Supplier payment' }) }); ``` **XOF**, **XAF**, **GHS** and **KES** mobile money are open. **UGX** is in early access on both rails, and an unpinned workspace is rejected at creation with `RAIL_UNAVAILABLE`. Enablement can be granted per rail, so ask your account manager for the rails you need. ### USD rails For USD payouts, three rails are surfaced by `GET /v1/payouts/available-rails?accountId=`: * **`fedwire`**: domestic wire to a US bank. Requires routing + account number on the beneficiary. * **`ach`**: batched same-day-to-3-day transfer to a US bank. Same required fields as fedwire. * **`swift`**: international wire via SWIFT. Requires BIC + IBAN/account number + the beneficiary's bank country. Destination coverage spans most major markets and expands as corridors are enabled, so it is not a fixed list: `GET /v1/payouts/available-rails?accountId=` reports, per saved account, whether SWIFT currently serves that account's destination country (`available` plus an `unavailableReason` when it does not). Over the API you pass `route` on every payout request; the beneficiary account just has to match it: a `swift` account for route `"swift"`, a US `bank_account` for `"ach"` / `"fedwire"`. In the dashboard, the rail chosen when the account was saved is remembered so senders aren't re-prompted. Beneficiary accounts intended for SWIFT must be created with `type: "swift"` and supply `swiftCode`, `accountNumber`, `beneficiaryCountry`, and (optionally) `bankName` / `beneficiaryAddress` in `data`. SWIFT payouts also require a purpose-of-payment document: request an upload URL from `POST /v1/payouts/pop-documents/upload-url`, upload the document, and pass the returned key as `purposeOfPaymentDocumentKey` on the payout. A SWIFT payout without it is rejected at creation. A SWIFT payout funded from an NGN balance is a cross-rate send: it passes through a quote (`POST /v1/quotes`), is debited in the source currency, and Zuba funds the USD outflow to the destination bank. A same-currency USD→USD SWIFT payout needs no quote and is sent 1:1 from your USD balance. ### Route Selection Logic The rail is determined by the beneficiary's account type and currency: ```mermaid theme={"dark"} flowchart TD A[Payout Request] --> B{Beneficiary account} B -->|iban: EUR / GBP| C[International Transfer] B -->|bank_account: USD| D[ACH or Domestic Wire] B -->|swift: USD| E[International Wire] B -->|bank_account: NGN / GHS / KES / UGX / XAF / XOF| F[Local Rails] B -->|mobile: XOF / XAF / GHS / KES / UGX| G[Mobile Money] B -->|wallet: USDC / USDT| H[On-chain Transfer] ``` ## Tracking & Status ### Payout Statuses | Status | Description | Next Actions | | - | - | - | | `created` | Payout has been created | Can be cancelled | | `queued` | Queued for processing | Can be cancelled; monitor for updates | | `processing` | Being processed by payment provider | Monitor for updates | | `paid` | Successfully delivered to beneficiary | Download receipt | | `failed` | Processing failed, or a paid payout was returned by the beneficiary's bank | Review error, retry. A failure before the payout was paid refunds the amount and the fee; a return refunds the amount, and `feeRefunded` on the payout says whether the fee came back with it | | `cancelled` | Cancelled by user or system | Funds returned | Once a payout's funds have been returned to your balance, the payout carries `feeRefunded`: `true` when the fee came back with the amount, `false` when the refund kept the fee. A payout whose funds were never returned has no `feeRefunded`. ### Cancelling a payout `POST /v1/payouts/{id}/cancel` cancels a payout that is still `created` or `queued`. The balance debited at creation (principal, fee and any conversion legs) is returned to the account it was taken from, and a `payout.cancelled` webhook is delivered. Retrying a cancel is always safe: an already-cancelled payout is returned again, and a balance return that had not completed is driven to completion. If the balance return does not complete, the call responds `500 INTERNAL_ERROR` while the payout is already `cancelled`; the payout is flagged for our operators and a later cancel call completes the return. A payout in any status other than `created`, `queued` or `cancelled` is refused with `409 PAYOUT_NOT_CANCELLABLE`; check its status with `GET /v1/payouts/{id}` instead. From the dashboard, a receipt can be downloaded for a payout in any status except `cancelled`. For a payout that is still in progress, the receipt reflects its current status. ### Amounts above corridor limits Every corridor has a per-transaction band, and a payout or payout quote outside it is rejected by the standard bounds checks. Amounts above corridor limits are served by the orders channel (`POST /v1/orders`) for order-enabled accounts: a quote-first flow where funds are held from your balance and the trade is executed by Zuba's desk within a stated execution window. See the [Trade Desk Orders guide](/guides/orders) or contact us to enable orders on your account. ### Real-time Tracking ```javascript Check Status theme={"dark"} const payout = await fetch(`https://api.zuba.com/v1/payouts/${payoutId}`, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const data = await payout.json(); console.log(data.status); // created, queued, processing, paid, failed, cancelled ``` ```curl Check Status theme={"dark"} curl "https://api.zuba.com/v1/payouts/123e4567-e89b-12d3-a456-426614174000" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Best Practices > **Beneficiary Validation** > > * Always validate beneficiary details before creating > * Use `GET /v1/payouts/requirements?currency=USD&route=swift` to check what the corridor requires before submitting. It reports the beneficiary fields and the `senderInfo` keys, each for an individual and for a business, and whether `senderInfo` can be omitted at all. Pass `country` with `route=mobile_money` to narrow the sender requirements to one destination country of a currency, such as the sender phone a mobile-money payout into Cameroon carries, or with `route=swift` for the recipient `rules` of the provider that pays out to the recipient's country > * Validate each field against the `rules` the requirements response reports for the recipient type. A corridor that addresses an individual recipient by first and last name separately reports a `name` rule and refuses a single-token name at submit, with a 400 naming `name`. A business recipient is its legal name and needs no split > * Keep `description` within the `payout.descriptionMaxLength` the same response reports: the strictest narration limit among the rails serving the currency (18 characters for GBP and GHS), counted after accented and special characters are converted for the payment rail (a crypto payout counts the text as entered). A longer value is refused at submit with a 400 whose detail carries the code `NARRATION_TOO_LONG` > * Store beneficiary IDs for repeat payments > * Keep beneficiary information up to date > **Sender Attribution** > > * Omit `senderInfo` to attribute the paying account as its KYB application was approved (your workspace, or the sub-account named in `Zuba-Account-Id`) where `sender.senderInfoRequired` is `false` for the corridor; supply it to pay out on behalf of a third party, and on a corridor that reports `true`, such as mobile money into Cameroon, which needs a sender phone no account record carries > * An account approved as an individual pays as that person, so on a corridor with rules for individual senders (XAF) it supplies the `sender.individual` fields even where `senderInfoRequired` is `false` > **Error Handling** > > * Implement proper error handling for all API calls > * Use exponential backoff for retries > * Monitor webhook notifications for status updates > * Log all transactions for audit purposes > **Security** > > * Never expose API keys in client-side code > * Validate webhook signatures > * Use HTTPS for all API communications > * Implement proper access controls ## Next Steps > **[First Payout Guide](/guides/first-payout)** > > Step-by-step guide to send your first payout > **[Batch Processing](/guides/batch-payouts)** > > Learn how to process multiple payouts efficiently > **[Webhooks](/guides/webhooks)** > > Set up real-time notifications for payout status > **[API Reference](/api-reference/introduction)** > > Complete API documentation for payouts # Batch Payouts Source: https://docs.zuba.com/guides/batch-payouts Process multiple payouts efficiently with batch operations Batch payouts let you create multiple payouts in a single request, ideal for payroll, affiliate payments, or any scenario requiring many transfers at once. This guide covers best practices and implementation details. > [!NOTE] > > Examples in this guide target the Sandbox (`https://api.sandbox.zuba.com`). > Swap in `https://api.zuba.com` for production. ## Why Use Batch Payouts * **Efficiency**: Create hundreds of payouts with a single API call, reducing API overhead * **Per-payout results**: Each item in the batch is processed independently. Failures are returned inline as `{ error, clientRef }` objects in the `201` response array, alongside the successful payout objects * **Up-front validation**: Request-level validation is all-or-nothing. One malformed item rejects the entire batch with a `400` before anything is created ## Single API Call for Multiple Payouts The Zuba API accepts both single payout objects and arrays of payout objects at the same endpoint. **Currency Fields:** `inputCurrency` is the currency from your account you'll be paying from, while `currency` is what the beneficiary will receive (automatic conversion if different): ```typescript theme={"dark"} // Batch payout data: EUR balance paying NGN salaries over local bank rails const batchPayouts = [ { clientRef: "SALARY-JAN-2026-001", amount: "850000.00", inputCurrency: "EUR", currency: "NGN", route: "bank_transfer", beneficiary: { id: "cc82fa1d-fc7a-478c-a734-d3bce40464e7" }, reference: "January 2026 salary - John Doe", description: "Monthly salary" }, { clientRef: "SALARY-JAN-2026-002", amount: "1200000.00", inputCurrency: "EUR", currency: "NGN", route: "bank_transfer", beneficiary: { id: "3f8e2a61-9b47-4c05-8d2e-1a6f0b9c4d73" }, reference: "January 2026 salary - Jane Smith", description: "Monthly salary" }, { clientRef: "SALARY-JAN-2026-003", amount: "650000.00", inputCurrency: "EUR", currency: "NGN", route: "bank_transfer", beneficiary: { id: "7d94c2e8-5f13-4a6b-9e07-2c8b1d4f6a90" }, reference: "January 2026 salary - Bob Wilson", description: "Monthly salary" } ]; const response = await fetch('https://api.sandbox.zuba.com/v1/payouts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' }, body: JSON.stringify(batchPayouts) }); const results = await response.json(); console.log(`Submitted ${results.length} payouts`); ``` > [!NOTE] > > `senderInfo` is only needed when paying out on behalf of a third party. When > omitted, the originator is the paying account as its KYB application was > approved: your workspace, or the sub-account named in `Zuba-Account-Id`. Business > originators use `type: "business"` with `companyName`, `registrationNumber`, > and `country`. For the live routes and destination currencies each rail supports, see [Payment Routes](/concepts/payouts#payment-routes) or call `GET /v1/payouts/available-rails`. ## Preparing Beneficiaries Before creating batch payouts, ensure all beneficiaries exist. Here's how to bulk create them: ```typescript theme={"dark"} async function createBeneficiaries(employeeData) { const beneficiaries = []; for (const employee of employeeData) { const beneficiaryData = { name: employee.name, email: employee.email, country: employee.country, accounts: [{ type: "bank_account", currency: "NGN", data: { bankCode: employee.bankCode, // Zuba code from GET /v1/banks (e.g. "ng_0001"); CBN and NIP codes stay accepted crAccount: employee.crAccount // 10-digit account number } }] }; try { const response = await fetch('https://api.sandbox.zuba.com/v1/beneficiaries', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' }, body: JSON.stringify(beneficiaryData) }); const beneficiary = await response.json(); beneficiaries.push({ ...employee, beneficiaryId: beneficiary.id }); } catch (error) { console.error(`Failed to create beneficiary for ${employee.name}:`, error); } } return beneficiaries; } ``` Account data fields differ per destination. For example, `iban` accounts (EUR/GBP) require `iban` and `accountHolderName`, with `bic` optional. See [Account Types](/concepts/payouts#account-types) for every shape; account shape is validated at submit rather than reported by `GET /v1/payouts/requirements`, which covers the beneficiary and sender fields a corridor needs. ## CSV Import Example Process payroll data from a CSV file: ```typescript theme={"dark"} import csv from 'csv-parser'; import fs from 'fs'; async function processBatchPayoutsFromCSV(filePath) { const rows = []; return new Promise((resolve, reject) => { fs.createReadStream(filePath) .pipe(csv()) .on('data', (row) => { // Validate row data if (!row.name || !row.amount || !row.crAccount) { console.warn('Skipping invalid row:', row); return; } rows.push({ name: row.name, email: row.email, country: row.country, bankCode: row.bankCode, crAccount: row.crAccount, amount: row.amount, // keep as a decimal string, e.g. "850000.00" currency: row.currency || 'NGN', reference: row.reference, description: row.description }); }) .on('end', async () => { try { // Create beneficiaries first const beneficiaries = await createBeneficiaries(rows); // Create batch payouts const payoutRequests = beneficiaries.map((emp, index) => ({ clientRef: `BATCH-${Date.now()}-${index}`, amount: emp.amount, inputCurrency: "EUR", currency: emp.currency, route: "bank_transfer", beneficiary: { id: emp.beneficiaryId }, reference: emp.reference, description: emp.description })); const response = await fetch('https://api.sandbox.zuba.com/v1/payouts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' }, body: JSON.stringify(payoutRequests) }); const results = await response.json(); resolve(results); } catch (error) { reject(error); } }) .on('error', reject); }); } ``` ## Monitoring Batch Progress [Webhooks](/guides/webhooks) are the primary mechanism for tracking payout status: subscribe once and receive an event every time a payout in the batch transitions (for example to `processing`, `paid`, or `failed`), with no polling. If you do need to poll, prefer `GET /v1/payouts` (the list endpoint) over per-ID requests. For a small batch, per-ID polling looks like this: ```typescript theme={"dark"} async function monitorBatchPayouts(payoutIds) { const statusUpdates = []; for (const payoutId of payoutIds) { try { const response = await fetch(`https://api.sandbox.zuba.com/v1/payouts/${payoutId}`, { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } }); const payout = await response.json(); statusUpdates.push({ id: payout.id, reference: payout.reference, status: payout.status, amount: payout.amount, beneficiaryName: payout.beneficiary.name }); } catch (error) { console.error(`Error checking payout ${payoutId}:`, error); } } return statusUpdates; } ``` ## Error Handling in Batches Batch creation is **not atomic**. There are two distinct failure modes: 1. **Validation failure (`400`)**: if any payout in the array fails request validation, the entire batch is rejected before anything is created. `details` contains per-field errors (`{ field, message }`) for the first invalid payout. 2. **Per-payout failure (inside a `201`)**: once validation passes, each payout is processed independently. A payout that cannot be created (for example, insufficient balance or a duplicate `clientRef`) is returned as an inline `{ error, clientRef }` element in the response array, where `error` is the rejection's message (for example `Insufficient balance`); the other payouts succeed normally. A **refused** row element carries `error` and `clientRef` and nothing else; a created row is the full payout object. A refused row has no `code` member, so `error` is the only discriminator a batch client has. A refused row carries the refusal's own message when the refusal is below `500`. A failure at `500` or above is not a refusal and is not surfaced: that row reads `Payout creation failed; see server logs for details.` and the cause stays in the server log. Some `error` values are a stable token — `QUOTE_REQUIRED`, `QUOTE_NOT_FOUND`, `QUOTE_MISMATCH`, `QUOTE_EXPIRED`, `QUOTE_ALREADY_USED`, `QUOTE_INTENT_MISMATCH`, `RAIL_UNAVAILABLE`, `SWIFT_UNAVAILABLE`, `CORRIDOR_NOT_ENABLED`, `AMOUNT_OUT_OF_BOUNDS` and `INSUFFICIENT_LIQUIDITY` — and others a sentence written for a person to read, such as `A payout with this client reference already exists.` The handler in [Error handling](/guides/error-handling#basic-error-handler) reads `message`, so map a row onto it first — `new ZubaAPIError(201, { message: row.error })` — and a token row then classifies exactly as the same refusal does on a single payout. Where `error` is a sentence rather than a token, the row carries no `code` to branch on, and that guide states how a body without a `code` is classified. Handle both: ```typescript theme={"dark"} async function createBatchWithErrorHandling(payoutRequests) { const response = await fetch('https://api.sandbox.zuba.com/v1/payouts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' }, body: JSON.stringify(payoutRequests) }); if (!response.ok) { // Validation failed: nothing in the batch was created. const error = await response.json(); if (Array.isArray(error.details)) { error.details.forEach(({ field, message }) => { console.error(`Validation error on ${field}: ${message}`); }); } throw new Error(error.message); } // 201: the array mixes created payouts and inline failures const results = await response.json(); const created = results.filter((r) => 'id' in r); const failed = results.filter((r) => 'error' in r); created.forEach((payout) => { console.log(`Created ${payout.clientRef}: ${payout.id} - ${payout.status}`); }); failed.forEach(({ clientRef, error }) => { console.error(`Failed ${clientRef}: ${error}`); }); // Alert on failed rows: they were NOT created and need follow-up return { created, failed }; } ``` ## Retries and Idempotency `clientRef` is unique per workspace, which makes retries safe by construction: * A single-payout request that reuses a `clientRef` is rejected with `400` `A payout with this client reference already exists.` (code `CLIENT_REF_ALREADY_USED`). * In a batch, a duplicate `clientRef` surfaces as an inline `{ error, clientRef }` element in the `201` response array, whose `error` is that same message and which carries no `code`; the other rows are unaffected. * Re-submitting a partially failed batch is therefore safe: rows that were already created come back as duplicate errors, and only rows refused before a payout was recorded create payouts. * A row refused for insufficient balance records no payout, so once the balance is funded it can be re-submitted with the same `clientRef`. The endpoint also supports an optional `Idempotency-Key` header for transport-level retries: a repeat with the same key and the same body replays the original response without creating new payouts; the same key with a different body is rejected with `422`; a repeat while the original request is still in flight returns a retryable `409`. Keys expire 24 hours after the original request completes. ## Best Practices ### 1. Batch Size Limits Keep batch sizes reasonable to avoid timeouts: ```typescript theme={"dark"} const BATCH_SIZE = 100; async function processBatchesInChunks(payouts) { const created = []; const failed = []; for (let i = 0; i < payouts.length; i += BATCH_SIZE) { const batch = payouts.slice(i, i + BATCH_SIZE); try { const results = await createBatchWithErrorHandling(batch); created.push(...results.created); failed.push(...results.failed); // Pace requests under the rate limits; on a 429, back off and retry // instead of a fixed sleep (see Retries and Idempotency above). await new Promise(resolve => setTimeout(resolve, 1000)); } catch (error) { console.error(`Batch ${i / BATCH_SIZE + 1} rejected:`, error); } } return { created, failed }; } ``` ### 2. Validation Before Submission Validate all payout data before submission. The lists below cover the live destination currencies and routes; prefer `GET /v1/payouts/available-rails` and `GET /v1/payouts/requirements` to check availability dynamically: ```typescript theme={"dark"} const SUPPORTED_CURRENCIES = ['EUR', 'USD', 'GBP', 'USDC', 'USDT', 'NGN', 'XOF', 'XAF', 'GHS', 'KES', 'UGX']; const SUPPORTED_ROUTES = ['bank_transfer', 'ach', 'fedwire', 'swift', 'crypto', 'mobile_money']; function validateBatchPayouts(payouts) { const errors = []; payouts.forEach((payout, index) => { if (!payout.clientRef) { errors.push(`Payout ${index + 1}: Missing clientRef`); } if (!payout.beneficiary?.id) { errors.push(`Payout ${index + 1}: Missing beneficiary.id`); } const amount = parseFloat(payout.amount); if (!payout.amount || isNaN(amount) || amount <= 0) { errors.push(`Payout ${index + 1}: Invalid amount`); } if (!SUPPORTED_CURRENCIES.includes(payout.currency)) { errors.push(`Payout ${index + 1}: Unsupported currency`); } if (!SUPPORTED_ROUTES.includes(payout.route)) { errors.push(`Payout ${index + 1}: Invalid route`); } }); return errors; } // Usage const validationErrors = validateBatchPayouts(batchPayouts); if (validationErrors.length > 0) { console.error('Validation errors:', validationErrors); return; } ``` ### 3. Progress Reporting Implement progress tracking for large batches: ```typescript theme={"dark"} async function createBatchWithProgress(payouts, onProgress) { const created = []; const failed = []; const total = payouts.length; for (let i = 0; i < total; i += BATCH_SIZE) { const batch = payouts.slice(i, i + BATCH_SIZE); const results = await createBatchWithErrorHandling(batch); created.push(...results.created); failed.push(...results.failed); // Report progress const processed = Math.min(i + BATCH_SIZE, total); onProgress(processed, total, (processed / total) * 100); } return { created, failed }; } // Usage const { created, failed } = await createBatchWithProgress(batchData, (current, total, percent) => { console.log(`Progress: ${current}/${total} (${percent.toFixed(1)}%)`); }); ``` ## Complete Batch Payout Example Here's a complete implementation: ```typescript theme={"dark"} import Decimal from 'decimal.js'; class BatchPayoutProcessor { constructor(apiToken, baseURL = 'https://api.sandbox.zuba.com/v1') { this.apiToken = apiToken; this.baseURL = baseURL; this.headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiToken}` }; } async processPayroll(employeeData) { try { // 1. Create beneficiaries const beneficiaries = await this.createBeneficiaries(employeeData); // 2. Prepare payout requests. clientRef is unique per workspace, so a // re-run of the same payroll period cannot double-pay anyone. const payoutRequests = beneficiaries.map((emp) => ({ clientRef: `PAYROLL-2026-01-${emp.employeeId}`, amount: emp.salary, // decimal string, e.g. "850000.00" inputCurrency: 'EUR', currency: 'NGN', route: 'bank_transfer', beneficiary: { id: emp.beneficiaryId }, reference: `Monthly salary - ${emp.name}`, description: `Payroll payment for ${emp.name}` })); // 3. Validate payouts const errors = this.validatePayouts(payoutRequests); if (errors.length > 0) { throw new Error(`Validation failed: ${errors.join(', ')}`); } // 4. Create batch payouts const results = await this.createBatch(payoutRequests); // 5. Return summary. amount is a decimal string on the wire: sum with // a decimal library, never float arithmetic; error rows carry no amount // and are excluded. const created = results.filter((r) => 'id' in r); const failed = results.filter((r) => 'error' in r); return { total: results.length, successful: created.length, failed, totalAmount: created .reduce((sum, p) => sum.plus(p.amount), new Decimal(0)) .toFixed(2), payouts: created }; } catch (error) { console.error('Payroll processing failed:', error); throw error; } } async createBatch(payouts) { const response = await fetch(`${this.baseURL}/payouts`, { method: 'POST', headers: this.headers, body: JSON.stringify(payouts) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message); } return await response.json(); } // ... other methods } // Usage const processor = new BatchPayoutProcessor(process.env.ZUBA_API_TOKEN); const results = await processor.processPayroll(employeeData); console.log(`Processed ${results.successful}/${results.total} payouts`); ``` ## Next Steps * Set up [webhooks](/guides/webhooks) to receive real-time status updates for your batch payouts * Learn about [error handling](/guides/error-handling) strategies for production batch processing * Explore the [API reference](/api-reference/introduction) for advanced payout options # Error Handling Source: https://docs.zuba.com/guides/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`. Some refusals carry `code` itself, which the handler reads first: the insufficient-balance refusal, and many of the `POST /v1/payouts` refusals, whose `message` is sometimes a sentence rather than a code. 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. A code outside this registry can span HTTP statuses, so key on the endpoint as well as the code: `CLIENT_REF_ALREADY_USED` is a `400` on `POST /v1/payouts` and a `409` on `POST /v1/orders`. > [!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. 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': // Nothing was recorded: fund the balance or reduce the amount, then // resend it with the same clientRef. 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 code `CLIENT_REF_ALREADY_USED` and the message `A 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=` 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`. A `failed` payout has been refunded to your balance (a payout the beneficiary's bank returned after it was paid is refunded its amount, and `feeRefunded` on the payout says whether the fee came back with it), and in rare cases the payment network later confirms that the money reached the beneficiary after all. Zuba reviews that confirmation before acting; if it is confirmed, your balance is debited again, the payout moves back to `paid`, and you receive a `payout.paid` webhook after the earlier `payout.failed`. A client that treats `failed` as final should keep handling `payout.paid` for a payout it already saw fail. ```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=` 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=` before resubmitting. * **`failed` payout**: held funds are returned to your balance automatically when the payout failed before it was paid. A payout the beneficiary's bank returns after it was paid is refunded once reviewed; `feeRefunded` on the payout says whether the fee came back with the amount. Fix the underlying cause (usually recipient details), then submit a new payout with a new `clientRef`. * **`INSUFFICIENT_BALANCE` (400)**: nothing was debited and no payout was recorded. Fund the balance, then submit the payout again; the same `clientRef` is accepted. 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 # Your First Payout Source: https://docs.zuba.com/guides/first-payout Step-by-step guide to sending your first payout with the Zuba API, from your EUR balance to a Nigerian bank account in NGN This guide walks you through creating and sending your first payout with the Zuba API. You'll create a beneficiary with a Nigerian bank account, then pay them NGN from your EUR balance; the currency conversion happens automatically. ## Prerequisites Before you start, make sure you have: * Valid API credentials (see [Authentication](/authentication)) * Your API base URL * A Sandbox or production account with sufficient balance ## Step 1: Create a Beneficiary First, create a beneficiary to receive the payout. A beneficiary represents a person or entity together with their payment account details. For NGN bank accounts, `bankCode` is the Zuba bank identifier from `GET /v1/banks` (e.g. `ng_0001` for Access Bank), and `crAccount` is the 10-digit account number. The 3-digit CBN short code (e.g. `044`) and the 6-digit NIP long code (e.g. `000014`) stay accepted. ```typescript theme={"dark"} const beneficiaryData = { name: "Adaeze Obi", email: "adaeze.obi@example.com", country: "NG", address: "12 Marina Road", city: "Lagos", postcode: "101233", accounts: [ { type: "bank_account", currency: "NGN", data: { bankCode: "ng_0001", crAccount: "1234567890" } } ] }; const response = await fetch('https://api.sandbox.zuba.com/v1/beneficiaries', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' }, body: JSON.stringify(beneficiaryData) }); const beneficiary = await response.json(); console.log('Beneficiary created:', beneficiary.id); ``` **id** `string` Unique UUID for the created beneficiary **accounts** `array` Array of payment accounts with their IDs and details > [!TIP] > > Required beneficiary fields vary by destination currency and by whether the > recipient is an individual or a business. Call > `GET /v1/payouts/requirements?currency=USD&route=swift` before submitting > to see exactly which fields the corridor needs of the recipient and of the > sender, for an individual and for a business of each, instead of discovering > them through validation errors. > > The response also lists `rules` a field value must satisfy, as a `pattern` to > test before submitting: a corridor that addresses an individual recipient by > first and last name separately reports one for `name`, and refuses a > single-token name with a 400 naming `name`. > > A business recipient is its legal name and needs no split. ## Step 2: Create Your First Payout Now create a payout to your beneficiary. **RECOMMENDED:** Reference the beneficiary by ID (from Step 1). **Currency Fields:** `inputCurrency` is the currency debited from your account, while `currency` is what the beneficiary receives. When they differ, Zuba converts automatically; no separate quote is needed for this corridor: ```typescript theme={"dark"} const payoutData = { clientRef: "PAYOUT-0001", amount: "80000.00", inputCurrency: "EUR", currency: "NGN", route: "bank_transfer", beneficiary: { id: beneficiary.id // Reference the beneficiary created in Step 1 }, reference: "Invoice payment", description: "Services rendered" }; const payoutResponse = await fetch('https://api.sandbox.zuba.com/v1/payouts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' }, body: JSON.stringify(payoutData) }); const payout = await payoutResponse.json(); console.log('Payout created:', payout.id); ``` > [!NOTE] > > `senderInfo` is optional. If omitted, the originator is the paying account > as its KYB application was approved: your workspace, or the sub-account named > in `Zuba-Account-Id`. Supply it only when paying out on behalf of a distinct > third party. Some corridors, such as mobile money in Ghana and Cameroon, > require it. Alternatively, provide the full beneficiary object to create a new beneficiary inline: ```typescript theme={"dark"} const payoutData = { clientRef: "PAYOUT-0001", amount: "80000.00", inputCurrency: "EUR", currency: "NGN", route: "bank_transfer", beneficiary: { name: "Adaeze Obi", email: "adaeze.obi@example.com", country: "NG", accounts: [{ type: "bank_account", currency: "NGN", data: { bankCode: "ng_0001", crAccount: "1234567890" } }] }, reference: "Invoice payment", description: "Services rendered" }; ``` See [Payment Routes](/concepts/payouts#payment-routes) for available routes and their characteristics. ## Step 3: Monitor Payout Status Track your payout's progress by checking its status: ```typescript theme={"dark"} const statusResponse = await fetch(`https://api.sandbox.zuba.com/v1/payouts/${payout.id}`, { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } }); const payoutDetails = await statusResponse.json(); console.log('Status:', payoutDetails.status); ``` A fresh payout moves through `created` → `queued` → `processing` → `paid`, so don't be surprised to see `created` or `queued` if you poll immediately after creation. See [Payout Statuses](/concepts/payouts#payout-statuses) for detailed status definitions. ## Step 4: Handle the Response A successful payout response includes: ```json theme={"dark"} { "id": "123e4567-e89b-12d3-a456-426614174000", "clientRef": "PAYOUT-0001", "reference": "Invoice payment", "description": "Services rendered", "amount": "80000.00", "currency": "NGN", "route": "bank_transfer", "type": "fiat", "status": "created", "fee": "2.50", "inputCurrency": "EUR", "inputAmount": "52.50", "fxRate": "1600.00", "beneficiary": { "id": "456e7890-e89b-12d3-a456-426614174000", "name": "Adaeze Obi", "type": "individual", "email": "adaeze.obi@example.com", "country": "NG", "accounts": [{ "id": "123e4567-e89b-12d3-a456-426614174001", "type": "bank_account", "currency": "NGN", "status": "active", "data": { "bankCode": "ng_0001", "crAccount": "1234567890" }, "createdAt": "2026-01-15T10:25:00Z", "updatedAt": "2026-01-15T10:25:00Z" }], "status": "active", "createdAt": "2026-01-15T10:25:00Z", "updatedAt": "2026-01-15T10:25:00Z" }, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z" } ``` The FX fields show what was actually debited: `inputAmount` is the total charged in `inputCurrency` (including the fee), and `fxRate` is the rate applied to convert to the destination currency. ## Error Handling Common errors when creating payouts: ```typescript theme={"dark"} try { const response = await fetch('https://api.sandbox.zuba.com/v1/payouts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' }, body: JSON.stringify(payoutData) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message); } const payout = await response.json(); } catch (error) { console.error('Payout failed:', error.message); // Handle specific errors: // - Insufficient balance (code INSUFFICIENT_BALANCE): add funds, then resend with the same clientRef // - Invalid beneficiary // - Invalid bank code (must be a bankCode from GET /v1/banks, or a recognised CBN/NIP code) // - Invalid account number (crAccount must be exactly 10 digits) // - Currency not supported } ``` ## Next Steps * Learn about [batch payouts](/guides/batch-payouts) for processing multiple payouts efficiently * Set up [webhook notifications](/guides/webhooks) to receive real-time payout updates * Explore [error handling](/guides/error-handling) for robust production implementations ## Complete Example Here's a complete example combining all steps: ```typescript theme={"dark"} async function sendFirstPayout() { const baseURL = 'https://api.sandbox.zuba.com/v1'; const headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' }; try { // 1. Create beneficiary const beneficiary = await fetch(`${baseURL}/beneficiaries`, { method: 'POST', headers, body: JSON.stringify({ name: "Adaeze Obi", email: "adaeze.obi@example.com", country: "NG", address: "12 Marina Road", city: "Lagos", postcode: "101233", accounts: [{ type: "bank_account", currency: "NGN", data: { bankCode: "ng_0001", crAccount: "1234567890" } }] }) }).then(r => r.json()); console.log('Beneficiary created:', beneficiary.id); // 2. Create payout using beneficiary ID (recommended) const payout = await fetch(`${baseURL}/payouts`, { method: 'POST', headers, body: JSON.stringify({ clientRef: "PAYOUT-0001", amount: "80000.00", inputCurrency: "EUR", currency: "NGN", route: "bank_transfer", beneficiary: { id: beneficiary.id // Reference the beneficiary by ID }, reference: "Welcome payment", description: "Welcome payment" }) }).then(r => r.json()); console.log('Payout created:', payout.id); return payout; } catch (error) { console.error('Error:', error); throw error; } } ``` # Trade Desk Orders Source: https://docs.zuba.com/guides/orders End-to-end guide to the orders flow: quote, order creation, funding, manual desk execution, completion, cancellation, refunds, and sandbox testing Orders are Zuba's channel for large currency transfers that are executed manually by our trade desk rather than dispatched straight through to a payment rail. You request a firm quote, create the order against it, fund it, and Zuba's desk sources liquidity and delivers to your beneficiary within an execution window stated on the order. The flow is deliberately different from payouts in three ways: * **Quote-first.** Every order starts from a firm quote (`POST /v1/quotes` with `intent: "order"`). There is no auto-priced path. A sub-account's order needs a quote made with the same `Zuba-Account-Id`. * **Fund-first.** The desk executes only against funds that are already secured. If your Zuba balance covers the order it is funded at creation; otherwise the order returns funding instructions and waits for your transfer. Zuba never extends credit, and nothing moves outbound until the order is funded. * **Manually executed.** A human trading team fills the order and a second operator approves the execution before any funds move. Orders complete within the execution window returned on the order, not in seconds. If you need automated, straight-through delivery for everyday amounts, use [payouts](/concepts/payouts) instead. Amounts above a payout corridor's cap are served by this channel. ## Orders vs payouts | | Payouts | Orders | | - | - | - | | Execution | Automated, straight-through | Manual, by Zuba's trade desk | | Pricing | Quote or auto-priced; rate plus a fixed fee | Always quote-first; a single all-in rate, no separate fee | | Funding | Your balance, at creation | Your balance, or a per-order transfer against funding instructions | | Typical size | Everyday amounts, subject to corridor caps | Large transfers, including amounts above payout corridor caps | | Speed | Seconds to minutes | Within the execution window on the order | | Cancellation | While still pending | Any time before the desk approves execution | ## Prerequisites * **Orders enabled on your account.** Orders are enabled per account, configured with the currency pairs (corridors) and transaction limits agreed during onboarding. Contact your account manager to get set up. * **A beneficiary** holding an **active account in the buy currency** (see [Beneficiaries](/api-reference/introduction)). When you buy a stablecoin (`USDC`, `USDT`, `EURC`), that account must be a crypto wallet on Ethereum or Solana, with an address valid on that network; when you buy a fiat currency, it must be a bank or other non-wallet account. If the beneficiary holds several active accounts in the buy currency, the order delivers to the oldest one that qualifies. * Valid API credentials (see [Authentication](/authentication)). The examples below use the sandbox host `https://api.sandbox.zuba.com`. In production, swap it for `https://api.zuba.com`; everything else is identical. ## Step 1: Get a firm quote Request a quote with `intent: "order"`. Fix exactly one side of the trade: * `fromAmount` fixes what you sell (the total debit), or * `toAmount` fixes what your beneficiary receives. ```bash theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/quotes" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "intent": "order", "fromCurrency": "USDT", "toCurrency": "NGN", "toAmount": "1500000.00" }' ``` ```json theme={"dark"} { "id": "550e8400-e29b-41d4-a716-446655440000", "fromCurrency": "USDT", "toCurrency": "NGN", "targetAmount": "1500000.00", "totalDebitAmount": "1006.50", "rate": "0.00067100", "expiresAt": "2026-07-08T12:00:30.000Z" } ``` Three things to know about order quotes: * **The rate is all-in.** `totalDebitAmount` is exactly what the order costs you; there is no separate fee added at order creation. `rate` is expressed as `fromCurrency` per 1 `toCurrency`. * **The quote is firm.** The rate you see is the rate your order executes at, regardless of market movement while the desk works the order. * **The quote is single-use and short-lived.** It typically expires within about 30 seconds; always read `expiresAt` rather than assuming a TTL. Create the order before it expires, or request a fresh quote. A quote request is rejected with `UNSUPPORTED_CORRIDOR` if the currency pair is not available on the orders channel, with `CORRIDOR_NOT_ENABLED` if it is available but not enabled for your account, or with `AMOUNT_OUT_OF_BOUNDS` if the amount is below the corridor minimum. ## Step 2: Create the order Create the order with the quote id and a beneficiary. The `Idempotency-Key` header is required. ```bash theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/orders" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 4d9f6c2a-8f6e-4b1a-9d3e-2c7b5a1e0f48" \ -d '{ "quotationId": "550e8400-e29b-41d4-a716-446655440000", "beneficiaryId": "cc82fa1d-fc7a-478c-a734-d3bce40464e7", "clientRef": "ORD-2026-0001", "purpose": "Supplier settlement" }' ``` Creation consumes the quote (it cannot be used again) and picks a funding path based on your available balance in the sell currency: **If your balance covers `fromAmount`**, it is debited into a hold reserved for this order in the same step, and the order is created directly in `funds_received`, queued for the desk: ```json theme={"dark"} { "id": "9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d", "quoteId": "550e8400-e29b-41d4-a716-446655440000", "beneficiaryId": "cc82fa1d-fc7a-478c-a734-d3bce40464e7", "status": "funds_received", "fromCurrency": "USDT", "toCurrency": "NGN", "fromAmount": "1006.5000", "toAmount": "1500000.0000", "rate": "0.00067100", "clientRef": "ORD-2026-0001", "purpose": "Supplier settlement", "fundingDeadlineAt": "2026-07-08T12:30:00.000Z", "executionDeadlineAt": "2026-07-08T18:00:00.000Z", "fundedAt": "2026-07-08T12:00:05.000Z", "createdAt": "2026-07-08T12:00:05.000Z", "updatedAt": "2026-07-08T12:00:05.000Z" } ``` **Otherwise**, no money moves: the order is created in `awaiting_funds` and the response carries a `settlement` block with the funding instructions, the exact amount to transfer, and a unique reference: ```json theme={"dark"} { "id": "1b7e4a90-2c5d-4e8f-9a0b-3c6d7e8f9a0b", "quoteId": "6a1f9511-f3ac-52e5-b827-557766551111", "beneficiaryId": "cc82fa1d-fc7a-478c-a734-d3bce40464e7", "status": "awaiting_funds", "fromCurrency": "XOF", "toCurrency": "USD", "fromAmount": "6500000.0000", "toAmount": "10000.0000", "rate": "650.00000000", "clientRef": "ORD-2026-0002", "settlement": { "settlementId": "8c2d5e7f-1a3b-4c5d-8e9f-0a1b2c3d4e5f", "method": "virtual_account", "requiredAmount": "6500000.0000", "transferAmount": "6507810.0000", "currency": "XOF", "reference": "SETTLE-4F7A2C91B0", "status": "open", "requirements": { "proofOfPayment": true }, "instructions": { "accountNumber": "CI93 CI201 01001 118817114214 07", "accountName": "Trans-Sahel Logistics SARL", "bankAddress": "Banque Atlantique, Abidjan", "country": "CI", "note": "Transfer the exact amount to this account, then upload your payment confirmation on the order so the funds can be confirmed and applied." }, "expiresAt": "2026-07-08T12:30:00.000Z" }, "fundingDeadlineAt": "2026-07-08T12:30:00.000Z", "createdAt": "2026-07-08T12:00:05.000Z", "updatedAt": "2026-07-08T12:00:05.000Z" } ``` Either way the quoted rate is locked: funding the order within the window executes at the rate on the order, regardless of market movement in between. **Idempotency.** Retrying the same request returns the original order rather than creating a duplicate. Two independent guards apply: the quote is single-use (a retry carrying the same `quotationId` under the same `Zuba-Account-Id` returns the order it already created), and `clientRef` is unique per account (reusing one on a different order returns `409 CLIENT_REF_ALREADY_USED`). Bank instruction fields depend on the collection account. A dedicated account's `accountName` is the legal holder from its bank RIB, while `bankAddress` identifies the institution or branch. Operator-configured pooled accounts can instead include `bankName`. Optional instruction fields are omitted when unavailable rather than returned as `null`. Treat `settlement.requirements` as the source of truth for any action required after the transfer. `proofOfPayment` is `true` only when the resolved receiving rail requires evidence for that deposit. Do not infer it from the sell currency, `method`, or free-text `instructions.note`. ## Step 3: Fund the order Balance-funded orders skip this step; they are already `funds_received`. For a settlement-funded order, transfer `transferAmount` to the `instructions` before `fundingDeadlineAt` (mirrored on `settlement.expiresAt`). `transferAmount` is what you send; `requiredAmount` is what must LAND to fund the order. They are equal on most rails. Where the receiving rail deducts a collection fee before the money settles, `transferAmount` is higher, so that the amount landing still covers `requiredAmount`. Always send `transferAmount`. * **Bank transfer** (`method: "virtual_account"`): send to the account in `instructions` and **include the `reference` in the transfer narration** wherever your bank supports one. Then inspect `requirements.proofOfPayment`: * **Narration-matched accounts**: a referenced transfer attributes to the order at any amount: a short payment sits on your balance until you top it up under the same reference, and an overpayment attributes with the excess left on your balance. Without the reference, a transfer auto-matches only where the rail credits it against your own account and the landed amount is exactly `requiredAmount`. On a shared collection account an unreferenced transfer is never matched automatically and is attributed by hand. * **When `proofOfPayment` is `true`**: after sending the transfer, request an upload URL with `POST /v1/orders/{id}/settlement/proof-upload-url` (body: the document's `contentType` and `contentLength`; PDF, JPG or PNG, up to 10 MB), `PUT` the confirmation document to it, then submit the returned `proofKey` with `POST /v1/orders/{id}/settlement/proof`. Funding is confirmed from the uploaded document, and the order moves on automatically once it is verified. Still include the `reference` on the wire: it speeds up verification. * **When `proofOfPayment` is `false`**: do not call either proof endpoint. Funding is reconciled from the receiving rail or by Zuba operations; the proof endpoints return `409 PROOF_OF_PAYMENT_NOT_REQUIRED`. * **Crypto transfer** (`method: "wallet_address"`): send **exactly** `transferAmount` (equal to `requiredAmount` on this rail, which deducts nothing) to your own deposit address from `instructions.addresses` (any listed network). The receiving address identifies you, so no reference is needed. When the transfer is confirmed, the funds are held into the order, the status moves to `funds_received`, `executionDeadlineAt` is set, and the `order.funds_received` webhook fires. **In the sandbox**, no real bank transfer exists to verify, so a settlement with `requirements.proofOfPayment: true` is confirmed **immediately when you submit the proof document**: upload any PDF/JPG/PNG and the order moves to `funds_received` on submission, letting you integration-test the full `awaiting_funds` lifecycle. Narration-matched funding is simulated with `POST /v1/sandbox/deposits` carrying the settlement `reference` in `narration`. If the funding window closes first, the order lands `expired` (webhook `order.expired`) and the settlement is closed. Nothing was held, so there is nothing to refund; a transfer that arrives after expiry is simply credited to your balance. On proof-required settlements this includes a confirmation you submitted before expiry: it is still verified and the funds are credited to your balance once confirmed. A confirmation cannot be submitted against an expired order, so if you sent the transfer but missed the window, contact support to have the payment applied. Re-quote and create a fresh order, which your now-funded balance can fund directly. ## Step 4: Wait for desk execution This is the part that makes orders different: **there is nothing to call.** Your order is now in a queue worked by Zuba's trading team. An operator fills the order at or better than your quoted rate, and a second operator independently reviews and approves the execution before any funds move. This dual-control step is why orders are not instant. What you can rely on: * An order the desk has not approved never outlives `executionDeadlineAt`. If execution is not approved by then, the order automatically fails with reason `review_expired` and your held funds are returned. You never need to chase an unapproved order. * While the desk works, the order reports `funds_received`. Once execution is approved it briefly reports `executing`; at that point it can no longer be cancelled and proceeds to completion. Any post-approval issue is resolved by Zuba operations. * Desk internals (venues, fills, operators) are never exposed on the API. Your contract is the quoted rate and the execution window. Track progress by webhook (recommended) or by polling: ```bash theme={"dark"} curl "https://api.sandbox.zuba.com/v1/orders/9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ## Step 5: Completion When the desk delivers to your beneficiary, the order lands `completed` and the `order.completed` webhook fires. For orders delivered over a wire rail, the response and webhook carry the outbound wire reference (`uetr`) for your beneficiary's bank to trace the payment. For orders delivered to a crypto wallet, they carry the on-chain transaction hash (`txHash`) instead: an Ethereum transaction hash or a Solana transaction signature, on the network of the beneficiary's wallet. ```json theme={"dark"} { "id": "9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d", "status": "completed", "uetr": "e8b1a9d2-7c4f-4a2b-8e6d-1f3a5b7c9d0e", "completedAt": "2026-07-08T14:21:07.000Z", "...": "..." } ``` The hold is settled: you paid exactly `fromAmount` at the quoted rate, and the ledger shows the order's legs against the order id. See [Ledger](/concepts/ledger) for how to reconcile. ## Order lifecycle ```mermaid theme={"dark"} %%{init: { "theme": "base", "themeVariables": { "fontFamily": "'Hanken Grotesk', system-ui, sans-serif", "fontSize": "13px", "primaryColor": "#fbf4ea", "primaryBorderColor": "#d8c8ad", "primaryTextColor": "#323236", "lineColor": "#a99e8c", "tertiaryColor": "#efe3d2", "tertiaryBorderColor": "#d8c8ad", "tertiaryTextColor": "#5b5347" } }}%% stateDiagram-v2 direction LR [*] --> funds_received : create, balance covers (funds held) [*] --> awaiting_funds : create, funding instructions issued awaiting_funds --> funds_received : transfer matched (funds held) awaiting_funds --> expired : funding window lapses awaiting_funds --> cancelled : you cancel (nothing held) awaiting_funds --> failed : desk rejects (nothing held) funds_received --> executing : desk approves execution funds_received --> cancelled : you cancel (funds returned) funds_received --> failed : desk rejects or window lapses (funds returned) executing --> completed : delivered (uetr or txHash recorded) completed --> [*] failed --> [*] expired --> [*] cancelled --> [*] ``` | Status | Meaning | | - | - | | `created` | The order exists but no funding path has resolved yet. Transient: creation moves straight on to `funds_received` or `awaiting_funds`, so polling never observes it (the `order.created` webhook still fires). | | `awaiting_funds` | Funding instructions are issued and the order is waiting for your transfer. No funds are held. | | `funds_received` | The source amount is held and the order is queued for the desk. | | `executing` | The desk has approved execution. The order can no longer be cancelled and proceeds to completion. | | `completed` | Delivered to your beneficiary. `uetr` is set for wire deliveries, `txHash` for deliveries to a crypto wallet. Terminal. | | `failed` | The desk could not execute the order, or the execution window lapsed before execution was approved. `failureReason` is set and any held funds are returned to your balance in full. Terminal. | | `expired` | The funding window closed before the order was funded. Nothing was held; late-arriving funds credit your balance. Terminal. | | `cancelled` | You cancelled before execution was approved. Any held funds are returned in full. Terminal. | ## Webhooks Order webhooks share the envelope, signing, and retry behaviour described in [Webhook Notifications](/guides/webhooks). Subscribe your endpoint to the `order.*` events: | Event | Fires when | | - | - | | `order.created` | The order is created. Arrives together with `order.funds_received` or `order.awaiting_funds`. | | `order.awaiting_funds` | Funding instructions are issued; the payload's `settlement` block carries the account or address, amount, and reference. | | `order.funds_received` | Funds are held and the order is queued for the desk. | | `order.completed` | The desk delivered; `uetr` present for wire deliveries, `txHash` for deliveries to a crypto wallet. | | `order.failed` | The order failed; `failureReason` set, any held funds returned. | | `order.expired` | The funding window closed before the order was funded. | There is no webhook for `executing` (desk review is internal) and none for `cancelled` (cancellation is your own synchronous API call). Every event for a settlement-funded order carries the `settlement` block, so you can correlate by reference at any point in the lifecycle; on balance-funded orders `settlement` is `null`. Example delivery: ```json theme={"dark"} { "id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "type": "order.completed", "createdAt": "2026-07-08T14:21:08.000Z", "test": false, "data": { "id": "9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d", "clientRef": "ORD-2026-0001", "status": "completed", "fromCurrency": "USDT", "toCurrency": "NGN", "fromAmount": "1006.5000", "toAmount": "1500000.0000", "rate": "0.00067100", "failureReason": null, "uetr": "e8b1a9d2-7c4f-4a2b-8e6d-1f3a5b7c9d0e", "txHash": null, "settlement": null, "fundingDeadlineAt": "2026-07-08T12:30:00.000Z", "executionDeadlineAt": "2026-07-08T18:00:00.000Z", "fundedAt": "2026-07-08T12:00:05.000Z", "completedAt": "2026-07-08T14:21:07.000Z", "createdAt": "2026-07-08T12:00:05.000Z" } } ``` ## Cancelling an order You can cancel an order at any point before the desk approves execution: ```bash theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/orders/9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d/cancel" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` A successful cancel returns the order with `status: "cancelled"`. While the order is `awaiting_funds` nothing is held, so cancellation simply closes it; after `funds_received` the held funds are returned to your balance in full. Retrying a successful cancel returns the already-cancelled order. Once execution has been approved (`executing`, or a terminal status other than `cancelled`), cancel returns `409 ORDER_NOT_CANCELLABLE`. Because approval can happen at any moment while the order is `funds_received`, treat cancellation as a race you can lose: a `409` means the trade is executing and will complete at the quoted rate. ## Failures and refunds A failed order is terminal and automatically refunds the full held amount to your balance; there is no partial execution and nothing to claim. (The desk can also reject an order that is still `awaiting_funds`: nothing was held, so there is nothing to refund.) `failureReason` carries a fixed taxonomy: | `failureReason` | Meaning | What to do | | - | - | - | | `execution_failed` | The desk could not execute the order. | Funds are back on your balance. Request a fresh quote and create a new order, or contact support if it recurs. | | `review_expired` | The execution window lapsed before the desk approved execution. | Same: funds are returned; re-quote and retry. | > [!NOTE] > > A `failed` order is a normal, fully-resolved outcome, not a stuck state: > the terminal status and the refund arrive together. Refunds always land on > your Zuba balance, never as an outbound transfer back to you; withdraw them > with a payout if you need them off-platform. Reconcile by matching the hold > and its reversal against the order id in your > [ledger history](/concepts/ledger). An `expired` order held nothing, so there is no refund; any transfer that arrives after expiry is credited to your balance. ## Listing orders `GET /v1/orders` returns your orders newest-first with cursor pagination, filterable by `status` and a `from`/`to` creation-time window: ```bash theme={"dark"} curl "https://api.sandbox.zuba.com/v1/orders?status=completed&limit=20" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` Use distinct `clientRef` values per order; they surface on the order, the webhooks, and your ledger history, and make reconciliation trivial. ## Orders for a sub-account If your workspace has sub-accounts, pass the sub-account's ID in the optional `Zuba-Account-Id` header on `POST /v1/orders` to create the order for that sub-account: it funds from the sub-account's own balance, spends only a quote made with the same header, and delivers only to a beneficiary saved under that sub-account (any other beneficiary is refused with `400 BENEFICIARY_NOT_FOUND`). The sub-account must be active; one that is not yet active is rejected with `422 ACCOUNT_NOT_ACTIVE`. With the same header, `GET /v1/orders` lists only that sub-account's orders, and `GET /v1/orders/{id}`, `POST /v1/orders/{id}/cancel`, `POST /v1/orders/{id}/settlement/proof-upload-url` and `POST /v1/orders/{id}/settlement/proof` act only on its orders. An order of your master workspace or of another sub-account gets the same answer as an id that does not exist, and is left as it was. A call without the header acts for the whole workspace and reaches every order. A header naming an account you do not own is rejected with `403 not_account_owner`. ## Testing in the sandbox The sandbox exercises the real order machinery end to end. Two mechanisms cover the parts that involve other actors in production: **Deterministic desk outcomes.** Orders are executed manually in production, so two magic **sell amounts** drive a funded order through the real desk workflow to a terminal state without a human operator. Any other amount stays in `funds_received` awaiting manual execution, exactly as in production. | Sell amount | Outcome | | - | - | | `1111.11` | Lands `completed`, with a `sandbox-` prefixed `uetr`, or for a delivery to a crypto wallet a `txHash` well-formed for the wallet's network (it names no real transaction). | | `2222.22` | Lands `failed` (`execution_failed`), with the held funds returned to your balance. | The match is on the order's source (sell) amount, is numeric (`1111.11` matches `1111.1100`), and works in any sell currency. In a currency with no minor unit (XOF, XAF, JPY, KRW, VND, UGX) the triggers are the whole units `1111` and `2222`, because a quote holds its amounts at the sell currency's own precision — `1111.11` XOF is quoted as `1111` and reaches the desk as that trigger, so either form works. **Simulated settlement funding.** The [sandbox deposit endpoint](/guides/sandbox-testing#step-1-fund-your-workspace) accepts a `narration` field; include an order's settlement `reference` in it to exercise the real automatic matcher. A complete settlement-funded run, using a fiat sell currency (settlements in crypto sell currencies fund through your own deposit addresses by exact amount, so the narration mechanism below does not apply to them): 1. Quote with the sell side pinned to a magic amount: `{"intent": "order", "fromCurrency": "USD", "toCurrency": "NGN", "fromAmount": "1111.11"}`. 2. Create the order with the returned quote id and a beneficiary holding an active account in the buy currency. With no balance in the sell currency, the order lands `awaiting_funds` and returns the `settlement` block (`order.created` + `order.awaiting_funds` fire). 3. Simulate your transfer: `POST /v1/sandbox/deposits` with `amount` set to `transferAmount`, `currency` set to the sell currency, and `narration` containing the settlement `reference`. The matcher attributes it, holds the funds, and fires `order.funds_received`. 4. The magic sell amount then drives the desk flow: `order.completed` (or `order.failed` for `2222.22`) arrives shortly after. Do not assume a fixed delay; wait on the webhook or poll. To test the balance-funded path instead, fund your sandbox balance first and create the order; it goes straight to `funds_received`. To test cancellation, use a non-magic amount (the order will sit in `awaiting_funds` or `funds_received`) and call the cancel endpoint. To test expiry, create a settlement-funded order and let the funding window lapse. See [Sandbox Testing](/guides/sandbox-testing#deterministic-orders) for the full sandbox reference. ## Error reference | Status | Code | Cause | | - | - | - | | 400 | `Idempotency-Key header is required` | Missing the required header on create. | | 400 | `QUOTE_NOT_FOUND` / `QUOTE_EXPIRED` / `QUOTE_ALREADY_USED` / `QUOTE_INTENT_MISMATCH` | The quote is unknown, past `expiresAt`, already consumed, or was not minted with `intent: "order"`. Request a fresh order quote. | | 400 | `UNSUPPORTED_CORRIDOR` | The currency pair is not available on the orders channel. | | 400 | `CORRIDOR_NOT_ENABLED` | The pair is not enabled for your account. Contact your account manager. | | 400 | `ORDERS_DISABLED` | Orders were disabled on your account after the quote was created. Contact your account manager. | | 400 | `AMOUNT_OUT_OF_BOUNDS` | The amount is below the corridor minimum. | | 400 | `BENEFICIARY_NOT_FOUND` | The beneficiary id does not exist on your account. | | 400 | `NO_SUITABLE_ACCOUNT_FOR_ORDER_CURRENCY` | The beneficiary has no active account in the buy currency, or, for a fiat buy, none that is a non-wallet account. | | 400 | `NO_DELIVERABLE_WALLET_FOR_ORDER_CURRENCY` | For a stablecoin buy: none of the beneficiary's active accounts in the buy currency is a wallet on Ethereum or Solana with an address valid on that network. Add such a wallet. | | 403 | `insufficient_scope` | Your API credentials lack the `create:orders` / `read:orders` scopes. Orders are not enabled on your account. | | 403 | `PAYMENTS_NOT_ENABLED` | Your account is not payments-enabled yet. Complete merchant verification to activate payments. The body also carries `error: "payments_not_enabled"`; branch on either. | | 409 | `CLIENT_REF_ALREADY_USED` | The `clientRef` was already used on a different order. | | 409 | `ORDER_NOT_CANCELLABLE` | Cancel arrived after execution was approved. | | 422 | `INSUFFICIENT_LIQUIDITY` | A live rate for the pair is temporarily unavailable. Retry shortly; contact support if it persists. | | 422 | `INSUFFICIENT_BALANCE` | Your balance does not cover `fromAmount` and settlement funding is not available for the sell currency on your account. | | 422 | limit exceeded | The order exceeds your per-transaction or monthly limit. Limits are agreed at onboarding; contact your account manager to raise them. | ## Next steps * [Webhook Notifications](/guides/webhooks) for endpoint setup, signature verification, and retries * [Sandbox Testing](/guides/sandbox-testing) for the full deterministic test-value reference * [Ledger](/concepts/ledger) for reconciling order holds, settlements, and refunds * [Orders API reference](/api-reference/introduction) for full request and response schemas # Sandbox Testing Source: https://docs.zuba.com/guides/sandbox-testing Play with the Zuba sandbox end-to-end: fund a workspace, register a webhook endpoint, trigger deterministic payout outcomes, receive the resulting webhooks, and price conversions, orders, and payouts with held quotes The Zuba sandbox is a full copy of the payment stack that never moves real money. It's where you build and test your integration: fund a workspace, point a webhook endpoint at your app, fire a payout with a known outcome, and watch the matching `payout.*` webhooks arrive. This page walks the whole loop. Everything here uses the sandbox base URL and a sandbox token: ```bash theme={"dark"} export ZUBA_TEST_TOKEN="" export ZUBA_API="https://api.sandbox.zuba.com" ``` See [Authentication](/authentication) for how to obtain a token. The magic account numbers and the deposit simulator exist only in the sandbox: in production the magic values get no special treatment, and the deposit simulator endpoint does not exist (it returns `404`). A new sandbox workspace is enabled for every corridor, collection method and currency the platform currently serves, across payouts, collections, conversions, orders and virtual accounts, with nothing to request; what is served follows the platform, so a corridor added later reaches your workspace within a few minutes without a change on your side. A workspace whose policy a Zuba operator has set keeps that policy instead. Sandbox fees and rates are illustrative rather than your contract terms, and the corridors enabled on your live workspace are the ones agreed at go-live. ## Lifecycle at a glance The full sandbox loop (fund, register, pay, receive) and the terminal branches a magic account number drives: ```mermaid theme={"dark"} %%{init: { "theme": "base", "themeVariables": { "fontFamily": "'Hanken Grotesk', system-ui, sans-serif", "fontSize": "13px", "actorBkg": "#2b2825", "actorBorder": "#ff6400", "actorTextColor": "#f1e7d6", "actorLineColor": "#c9bca6", "signalColor": "#a99e8c", "signalTextColor": "#6f665c", "labelBoxBkgColor": "#fbf4ea", "labelBoxBorderColor": "#ff6400", "labelTextColor": "#323236", "noteBkgColor": "#efe3d2", "noteBorderColor": "#d8c8ad", "noteTextColor": "#5b5347", "sequenceNumberColor": "#fbf4ea" } }}%% sequenceDiagram autonumber participant You as Your app participant Zuba as Zuba API participant EP as Your endpoint rect rgba(255,100,0,0.04) Note over You,EP: Step 1–2 · fund, register, smoke-test You->>Zuba: POST /v1/sandbox/deposits Note right of Zuba: trusted ledger credit · deposit.completed You->>Zuba: GET /v1/ledger/balances · confirm funds You->>Zuba: POST /v1/webhooks Zuba-->>You: signingSecret · whsec_… (shown once) You->>Zuba: POST /v1/webhooks/:id/test Zuba->>EP: webhook.test · "test": true EP-->>Zuba: 200 OK · signature verified end Note over You,EP: Step 3–4 · deterministic payout You->>Zuba: POST /v1/payouts · account 0000000000 … 0000000006 Zuba-->>You: 201 · status = created Note right of Zuba: async · magic account matched:
outcome synthesised, no provider dispatch alt 0000000005 cancelled in its ~60s in created You->>Zuba: POST /v1/payouts/:id/cancel Note right of Zuba: cancelled · hold returned ·
never moves to processing Zuba->>EP: payout.cancelled else any other payout (0000000005 once its ~60s end) Zuba->>EP: payout.processing EP-->>Zuba: 200 OK alt 0000000000 / …3 / …5 → paid Note over Zuba: ~3s · async settle (…3: ~60s) Zuba->>EP: payout.paid else 0000000001 / …4 → failed Note over Zuba: ~3s · async settle (…4: ~60s) Note right of Zuba: hold reversed · failureCategory =
technical_error Zuba->>EP: payout.failed else 0000000002 → failed Note over Zuba: ~3s · async settle Note right of Zuba: hold reversed · failureCategory =
recipient_details Zuba->>EP: payout.failed else 0000000006 → held by screening in processing alt you release it You->>Zuba: POST /v1/sandbox/payouts/:id/release Note over Zuba: ~3s · async settle Zuba->>EP: payout.paid else you decline it You->>Zuba: POST /v1/sandbox/payouts/:id/decline Note right of Zuba: hold reversed · failureCategory =
payment_not_approved Zuba->>EP: payout.failed end end end EP-->>Zuba: 200 OK Note over Zuba,EP: non-2xx / timeout → retried with backoff · 12 attempts max ``` ## Step 1: Fund your workspace You need a balance before you can pay out. A sandbox deposit is a trusted ledger credit scoped to your own workspace, with no provider and no fraud screening. It exists purely to give you funds to spend. To add fiat test funds, call `POST /v1/sandbox/deposits`, which credits the workspace of the calling token. The endpoint exists **only in the sandbox**; in production it returns `404`. To exercise your deposit integration itself (payment instructions, a failure, the webhooks), create the deposit through `POST /v1/deposits` instead, as [Deposits](#deposits) describes. A simulated deposit always lands in your own workspace, so the request body takes no `clientId`: ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/sandbox/deposits" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "amount": "1000.00", "currency": "USD" }' ``` | Field | Required | Description | | - | - | - | | `amount` | Yes | Amount to credit, as a major-unit decimal string (e.g. `"1000.00"`). Must be greater than zero. | | `currency` | Without `accountNumber`, or with a magic account number | Currency code (ISO 4217, case-insensitive), e.g. `USD`, `EUR`, `NGN`. With the `accountNumber` of an issued account, the account sets the currency, and a different value returns `400`. | | `accountNumber` | No | The `accountNumber` or `iban` of one of your active [virtual accounts](#virtual-accounts). The deposit arrives at that account instead of topping up a balance directly. A [magic account number](#step-3-trigger-a-deterministic-payout) selects its documented outcome instead. | | `clientRef` | No | Idempotency key, unique per workspace. A value is generated when omitted. | | `narration` | No | Simulated transfer narration, as a bank would pass it through. Include an order settlement reference (`SETTLE-…`) to exercise [automatic settlement matching](/guides/orders#step-3-fund-the-order) for a settlement-funded order. | The response returns immediately with `status: "processing"`: ```json theme={"dark"} { "id": "123e4567-e89b-12d3-a456-426614174000", "amount": "1000.00", "currency": "USD", "status": "processing" } ``` The credit is booked synchronously and settles to `completed` a moment later, the same lifecycle as a real deposit. Reaching `completed` delivers `deposit.completed` to any endpoint subscribed to it. Confirm the funds landed with `GET /v1/ledger/balances` (or `GET /v1/deposits/:id` for the deposit's own status) before you pay out. ## Step 2: Register a webhook endpoint To receive callbacks, point Zuba at an HTTPS URL your app controls. For local development, expose your server with a tunnel first: ```bash theme={"dark"} ngrok http 3000 # → https://abc123.ngrok.io ``` Create the endpoint and subscribe to the events you care about: ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/webhooks" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://abc123.ngrok.io/webhooks/zuba", "events": ["payout.processing", "payout.paid", "payout.failed", "payout.cancelled", "webhook.test"] }' ``` The response contains the **signing secret once**. Store it now, you cannot retrieve it again: ```json theme={"dark"} { "id": "123e4567-e89b-12d3-a456-426614174000", "url": "https://abc123.ngrok.io/webhooks/zuba", "events": ["payout.processing", "payout.paid", "payout.failed", "payout.cancelled", "webhook.test"], "enabled": true, "signingSecret": "whsec_a1b2c3d4e5f6...", "signingSecretHint": "e5f6", "createdAt": "2026-06-30T10:00:00.000Z", "updatedAt": "2026-06-30T10:00:00.000Z" } ``` > [!WARNING] > > The `signingSecret` is shown only on create and on `POST /v1/webhooks/:id/rotate-secret`. If you lose it, rotate to get a new one. Use it to verify every incoming signature. See [Webhook Notifications](/guides/webhooks#verifying-signatures). Subscribable event types: `payout.processing`, `payout.paid`, `payout.failed`, `payout.reverted`, `payout.cancelled`, `order.created`, `order.awaiting_funds`, `order.funds_received`, `order.completed`, `order.failed`, `order.expired`, `virtual_account.active`, `virtual_account.failed`, `deposit.completed`, `deposit.failed`, `application.under_review`, `application.more_info_requested`, `application.approved`, `application.rejected`, `account.created`, `webhook.test`. What each event carries and when it fires is covered in [Webhook Notifications](/guides/webhooks#webhook-events). ### Send a test webhook Before triggering a real payout, confirm your endpoint receives and verifies a delivery. This fires a `webhook.test` event to the registered URL immediately: ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/webhooks/123e4567-e89b-12d3-a456-426614174000/test" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" ``` ```json theme={"dark"} { "eventId": "evt_...", "message": "Test event enqueued" } ``` Your endpoint should receive a POST with the standard envelope and `"test": true`. If your signature check passes here, it will pass for real events too. ## Step 3: Trigger a deterministic payout For predictable outcomes, send payouts using the documented magic account numbers below. Any payout that uses these values is guaranteed to resolve to the documented terminal state, so you can write tests that assert on success and failure paths reliably. The outcome is determined by the **beneficiary identifier** alone and works across **every bank and mobile money corridor**. For bank transfers that identifier is the **account number**; for **mobile money** it is the **phone number** (see [Mobile money](#mobile-money) below). Put one of these values in the identifier field for the corridor: `crAccount` for NGN/GHS, `accountNumber` for USD (and the sandbox-only ZMW/MWK/EGP), `phoneNumber` for mobile money. EUR, GBP and CHF payouts take a full IBAN, so they use the magic IBANs listed under [IBAN payouts](#iban-payouts-eur-gbp-and-chf) instead. The bank code, routing number, BIC, and mobile provider are ignored when matching. | Account number | Resolved holder name | Outcome | | - | - | - | | `0000000000` | `Sandbox: paid` | The payout transitions to `paid`. | | `0000000001` | `Sandbox: failed` | The payout transitions to `failed`. | | `0000000002` | `Sandbox: invalid account` | The payout transitions to `failed` as an invalid account. | | `0000000003` | `Sandbox: slow paid` | The payout stays in `processing` for about 60 seconds, then transitions to `paid`. | | `0000000004` | `Sandbox: slow failed` | The payout stays in `processing` for about 60 seconds, then transitions to `failed`. | | `0000000005` | `Sandbox: held then paid` | The payout stays in `created` for about 60 seconds, then moves to `processing` and transitions to `paid`. | | `0000000006` | `Sandbox: screening hold` | Screening holds the payout in `processing` until you release or decline it (see [Screening holds](#screening-holds)). | Every payout is created with status `created`. The first three move to `processing` asynchronously moments later (delivering the `payout.processing` webhook) and reach their terminal state after about 3 seconds, so you can exercise the full `created` → `processing` → terminal lifecycle regardless of the outcome. `0000000003` to `0000000005` exercise slower paths. `0000000003` and `0000000004` move to `processing` just as quickly but stay there for about 60 seconds before they settle, which lets you test how your app shows a payout that is still in flight and what it does while it waits for the terminal webhook. `0000000005` stays in `created` for about 60 seconds. During that window `POST /v1/payouts/{id}/cancel` succeeds: the payout moves to `cancelled`, the held funds return to your balance, and it never moves to `processing`. If you leave it alone, it moves to `processing` when the window ends and settles `paid` about 3 seconds later, like `0000000000`. The same account numbers work for simulated deposits. Send one as `accountNumber` to `POST /v1/sandbox/deposits`, together with a `currency`: a number whose payout ends `paid` (`0000000000`, `0000000003` or `0000000005`) credits your own workspace in that currency, exactly as a deposit without `accountNumber` does, and a number whose payout ends `failed` (`0000000001`, `0000000002` or `0000000004`) records a deposit that ends `failed` at once (the response carries `status: "failed"`), credits nothing, and delivers `deposit.failed` to an endpoint you have registered for it. The payout's 60-second wait has no deposit counterpart, and `0000000006` credits like `0000000000` because a screening hold applies to payouts only. Any other account or phone number is treated as a regular payout. Regular sandbox payouts go through the same `created` → `processing` lifecycle and settle `paid` about 3 seconds later; no funds move anywhere. Crypto (`wallet`) payouts have no magic values and are not simulated: the wallet address is never matched, and the payout is sent on a public test network, so its outcome depends on that network and is not guaranteed. The sandbox does not ask for a `dateOfBirth` on an individual beneficiary or on an individual `senderInfo`, so it accepts a crypto payout without one; production refuses it. The paying account's approved application is still checked: a crypto payout with no `senderInfo`, from a workspace or sub-account approved as an individual with no date of birth on its application, is refused with `ORIGINATOR_NOT_ON_RECORD` in the sandbox as in production (see [Travel Rule](/concepts/payouts#travel-rule)). Fraud and compliance screening passes every other payout and every deposit in the sandbox. To reproduce a screening hold, use `0000000006` (see [Screening holds](#screening-holds)). UGX bank transfer and mobile money payouts are in early access in production and open in the sandbox. A UGX payout from a workspace that has not been enabled for UGX is refused with `RAIL_UNAVAILABLE` in production (see [Payouts](/concepts/payouts)); the sandbox accepts it and settles it like any other corridor. The **resolved holder name** column shows the value the sandbox account-name resolver returns for each magic value (NGN bank transfers only). When you're using these from the dashboard, the verified-account banner displays this name so you can tell at a glance which scenario you're about to trigger. The sandbox answers the other account lookups from sandbox data too. Resolving any other NGN account number at a listed bank returns the holder name `Sandbox Account `, the NGN bank list is a fixed list, and resolving a GB sort code and account number returns a valid IBAN derived from them. ### Example: a guaranteed-success NGN payout ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/payouts" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clientRef": "sandbox-001", "amount": "1000.00", "currency": "NGN", "route": "bank_transfer", "beneficiary": { "name": "Sandbox Test Recipient", "country": "NG", "accounts": [ { "type": "bank_account", "currency": "NGN", "data": { "bankCode": "ng_0019", "crAccount": "0000000000", "accountHolderName": "Sandbox Test Recipient" } } ] } }' ``` The response returns `"status": "created"`; the payout moves to `processing` asynchronously and you will then receive a `payout.paid` webhook (or you can poll `GET /v1/payouts/:id` until `status` is `paid`). ### Example: a guaranteed-failure NGN payout Switch the `crAccount` to `0000000001` to assert your error-handling path: ```json theme={"dark"} { "data": { "bankCode": "ng_0019", "crAccount": "0000000001", "accountHolderName": "Sandbox Test Recipient" } } ``` The payout moves through `processing` and transitions to `failed` after about 3 seconds, the held funds are returned to your balance, and the `payout.failed` webhook is delivered. `0000000002` fails the same way, and the two report different failure types: | Account number | `failureCategory` | Reported as | | - | - | - | | `0000000001` (and `0000000004`) | `technical_error` | A failure on Zuba's side, with the same message a live technical failure carries. | | `0000000002` | `recipient_details` | A failure because the recipient's account details are wrong, with the same message a live invalid account carries. | Branch on `status` and `failureCategory`, not on the failure text. `failureReason` is a human-readable message intended for display and its wording is not part of the API contract. `failureCategory` is the enumerated field for branching on failure type. ### Example: a guaranteed-success USD payout The same account numbers work outside NGN. For a USD payout, put the magic value in `accountNumber`: ```json theme={"dark"} { "data": { "accountNumber": "0000000000", "routingNumber": "021000021", "accountHolderName": "Sandbox Test Recipient" } } ``` ### IBAN payouts (EUR, GBP and CHF) An IBAN starts with a country code and two check digits, so a bare account number such as `0000000000` fails the IBAN format check when the payout is created. IBAN payouts use these IBANs instead, one set per currency. Each one is a valid IBAN whose account number ends in the same digits as the account-number scheme. The outcome depends only on the IBAN, so use these whichever country your beneficiary is in. | Outcome | EUR (German IBAN) | GBP (UK IBAN) | CHF (Swiss IBAN) | | - | - | - | - | | `paid` | `DE68370400440000000000` | `GB80NWBK60161300000000` | `CH2100762000000000000` | | `failed` | `DE41370400440000000001` | `GB53NWBK60161300000001` | `CH9100762000000000001` | | `failed` (invalid account) | `DE14370400440000000002` | `GB26NWBK60161300000002` | `CH6400762000000000002` | | `paid` after about 60 seconds in `processing` | `DE84370400440000000003` | `GB96NWBK60161300000003` | `CH3700762000000000003` | | `failed` after about 60 seconds in `processing` | `DE57370400440000000004` | `GB69NWBK60161300000004` | `CH1000762000000000004` | | `paid` after about 60 seconds in `created` (cancellable) | `DE30370400440000000005` | `GB42NWBK60161300000005` | `CH8000762000000000005` | | Held by screening until released or declined | `DE03370400440000000006` | `GB15NWBK60161300000006` | `CH5300762000000000006` | Put the IBAN in the `iban` field of an `iban` account, written without spaces as in the table. Any other IBAN is treated as a regular payout. An IBAN payout needs the recipient's full address (`address`, `city` and `postcode` on the beneficiary), as in production. A CHF payout is paid only from an XOF balance; in production it is enabled per client on request, while the sandbox has it enabled. See [When a quote is required](/concepts/payouts#when-a-quote-is-required) for its funding rules. ```json theme={"dark"} { "type": "iban", "currency": "EUR", "data": { "iban": "DE41370400440000000001", "accountHolderName": "Sandbox Test Recipient" } } ``` ### Mobile money Mobile money payouts carry the beneficiary as a **phone number**, so the magic value goes in the `phoneNumber` field instead of an account number. A phone number must be a valid number for its country, so (unlike the single universal account number) each mobile corridor has its own set of seven magic numbers. The trailing digits match the account-number scheme: **`…00` → paid, `…01` → failed, `…02` → invalid account, `…03` → slow paid, `…04` → slow failed, `…05` → held then paid, `…06` → screening hold**, with the same timing as the matching account number. | Country | Paid | Failed | Invalid account | Slow paid | Slow failed | Held then paid | Screening hold | | - | - | - | - | - | - | - | - | | Ghana (GH) | `+233200000000` | `+233200000001` | `+233200000002` | `+233200000003` | `+233200000004` | `+233200000005` | `+233200000006` | | Côte d'Ivoire (CI) | `+2250500000000` | `+2250500000001` | `+2250500000002` | `+2250500000003` | `+2250500000004` | `+2250500000005` | `+2250500000006` | | Senegal (SN) | `+221701234500` | `+221701234501` | `+221701234502` | `+221701234503` | `+221701234504` | `+221701234505` | `+221701234506` | | Mali (ML) | `+22365012300` | `+22365012301` | `+22365012302` | `+22365012303` | `+22365012304` | `+22365012305` | `+22365012306` | | Burkina Faso (BF) | `+22670123400` | `+22670123401` | `+22670123402` | `+22670123403` | `+22670123404` | `+22670123405` | `+22670123406` | | Benin (BJ) | `+2290195123400` | `+2290195123401` | `+2290195123402` | `+2290195123403` | `+2290195123404` | `+2290195123405` | `+2290195123406` | | Togo (TG) | `+22890112300` | `+22890112301` | `+22890112302` | `+22890112303` | `+22890112304` | `+22890112305` | `+22890112306` | | Cameroon (CM) | `+237671234500` | `+237671234501` | `+237671234502` | `+237671234503` | `+237671234504` | `+237671234505` | `+237671234506` | | Kenya (KE) | `+254731234000` | `+254731234001` | `+254731234002` | `+254731234003` | `+254731234004` | `+254731234005` | `+254731234006` | | Uganda (UG) | `+256701234500` | `+256701234501` | `+256701234502` | `+256701234503` | `+256701234504` | `+256701234505` | `+256701234506` | | Mozambique (MZ) | `+258841234500` | `+258841234501` | `+258841234502` | `+258841234503` | `+258841234504` | `+258841234505` | `+258841234506` | | China (CN) | `+8613800138000` | `+8613800138001` | `+8613800138002` | `+8613800138003` | `+8613800138004` | `+8613800138005` | `+8613800138006` | The `country` on the account must match the number's country, and `mobileProvider` must be a valid provider for that country, but the specific provider does not affect the outcome. For Mozambique's providers and a complete MZN request, see [Mozambique (MZN) mobile money](#mozambique-mzn-mobile-money). The China numbers are Alipay wallets for a CNY payout, a `mobile` account with `mobileProvider` `alipay`; see [When a quote is required](/concepts/payouts#when-a-quote-is-required) for what a CNY payout carries. > [!NOTE] > > Cameroon (CM) payouts additionally require the sender's KYC on the payout > request via `senderInfo`. An individual sender must supply `firstName`, > `lastName`, `phoneNumber`, `fundOrigin`, `gender`, and `country`, e.g. > `"senderInfo": { "type": "individual", "firstName": "Ada", "lastName": "Eze", "phoneNumber": "+237650000000", "fundOrigin": "SALARY", "gender": "F", "country": "CM" }` > (a business sender instead requires `companyName`, `registrationNumber`, and > `country`). If any of those is missing, a CM payout is rejected with a > `400` at creation, before the magic number is evaluated. A guaranteed-success GHS mobile payout: ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/payouts" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clientRef": "sandbox-mobile-001", "amount": "100.00", "currency": "GHS", "route": "mobile_money", "beneficiary": { "name": "Sandbox Test Recipient", "country": "GH", "accounts": [ { "type": "mobile", "currency": "GHS", "data": { "mobileProvider": "mtn", "phoneNumber": "+233200000000", "country": "GH" } } ] } }' ``` Switch `phoneNumber` to `+233200000001` for a guaranteed failure, `+233200000002` for an invalid-account failure, `+233200000003` to `+233200000005` for the slow and held outcomes, or `+233200000006` for a screening hold, exactly as with the bank account numbers above. ### Screening holds A payout to `0000000006`, or to its IBAN or mobile number form, is held by fraud screening, the way a payout under compliance review is held in production. Screening runs moments after the payout is created: the payout moves to `processing`, delivers `payout.processing`, and stays there until the hold is resolved. It can't be cancelled while it waits, because it has already left `created`. In production a compliance reviewer resolves the hold. In the sandbox you resolve it yourself, with the same credentials you use to create payouts: * `POST /v1/sandbox/payouts/{id}/release` approves the payout. It continues as an ordinary sandbox payout, settles `paid` about 3 seconds later, and delivers `payout.paid`. * `POST /v1/sandbox/payouts/{id}/decline` refuses it. The payout moves to `failed` with `failureCategory` `payment_not_approved`, the held funds return to your balance, and `payout.failed` is delivered. ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/sandbox/payouts/$PAYOUT_ID/release" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" ``` Each call answers `200` with the payout. Repeating the call that resolved the hold answers `200` with the payout again. The other call, or either call for a payout screening is not holding, answers `409` with the code `STATE_CONFLICT`, and a payout that is not your workspace's answers `404`. With `Zuba-Account-Id` set, both calls act only on that sub-account's payouts, and any other payout answers `404` as well. The release and decline endpoints exist only in the sandbox. ### Sandbox-only payout currencies Four additional payout currencies are simulated in the sandbox: **ZMW** (Zambia), **MWK** (Malawi) and **EGP** (Egypt) over bank transfer, and **MZN** (Mozambique) over mobile money. Use them to build and test flows for these markets before they go live. In production a payout to ZMW, MWK or EGP is rejected at creation, as is an MZN payout from a workspace that has not been enabled for it. Payouts to these currencies settle like every other sandbox payout: the magic values above drive the same outcomes and timing, and any other account or phone number settles as `paid`. Fund them cross-currency from an existing balance, e.g. hold USD and pay out ZMW: ```json theme={"dark"} { "clientRef": "ZMW-TEST-001", "amount": "250.00", "currency": "ZMW", "route": "bank_transfer", "inputCurrency": "USD", "beneficiary": { "name": "Chanda Mwansa", "country": "ZM", "type": "individual", "accounts": [ { "type": "bank_account", "currency": "ZMW", "data": { "bankCode": "260001", "accountNumber": "1234567890", "accountHolderName": "Chanda Mwansa" } } ] } } ``` Any `bankCode` is accepted. The same shape applies for `MWK` and `EGP`, with the magic values going in `accountNumber`. #### Mozambique (MZN) mobile money MZN pays out to mobile money wallets, not bank accounts, so an MZN payout uses `route: "mobile_money"` and a `mobile` beneficiary account. Hold USD and pay out MZN: ```json theme={"dark"} { "clientRef": "MZN-TEST-001", "amount": "250.00", "currency": "MZN", "route": "mobile_money", "inputCurrency": "USD", "beneficiary": { "name": "Maria Sitoe", "country": "MZ", "type": "individual", "accounts": [ { "type": "mobile", "currency": "MZN", "data": { "country": "MZ", "phoneNumber": "+258841234500", "mobileProvider": "mpesa" } } ] } } ``` * `mobileProvider` is `mpesa` or `vodacom` for an M-Pesa wallet, and `emola` or `movitel` for an e-Mola wallet. * `phoneNumber` is a Mozambican mobile number in international format. `+258841234500` settles `paid`; use `+258841234501` for `failed` or `+258841234502` for an invalid-account failure, as in the [mobile money table](#mobile-money). * `amount` is in MZN and must be between **5** and **75,000**; a payout outside that range is rejected at creation. * No quote is needed: the payout is priced at the current USD to MZN rate when it is created. A USDC balance can fund it the same way. Fund your balance first with the [deposit simulator](#step-1-fund-your-workspace), or the payout is rejected for insufficient balance. ## Step 4: Receive the webhooks A payout is created with status `created`, moves to `processing` asynchronously, and then transitions to a terminal state (`paid` or `failed`). A bank or mobile money payout settles about 3 seconds after entering `processing`. The slow and held magic values are the exception: `…03` and `…04` stay in `processing` for about 60 seconds, and `…05` waits about 60 seconds in `created` first (see the [magic account numbers](#step-3-trigger-a-deterministic-payout)). Do not assume a fixed delay in your code. Each payout delivers the full sequence: 1. `payout.processing` 2. `payout.paid` **or** `payout.failed` A payout cancelled while it is still `created`, such as a `…05` payout during its wait, never moves to `processing` and delivers `payout.cancelled` instead. A `…06` payout delivers `payout.processing` when screening holds it and its terminal webhook only once you [release or decline it](#screening-holds). Track completion in one of two ways: * **Webhooks**: the recommended approach. Treat the webhook as the source of truth. Each delivery is a JSON POST carrying `X-Zuba-Signature` and `X-Zuba-Timestamp`. **Always verify the signature before processing**. Copy-paste handlers for Node, Python, Go, and Java are in [Webhook Notifications](/guides/webhooks#code-examples). * **Polling**: `GET /v1/payouts/:id` and check the `status` field. Avoid tight polling loops; one request every few seconds is plenty. ### Inspect deliveries Didn't see a callback? Check what Zuba attempted, with HTTP status codes and (truncated) response bodies: ```bash theme={"dark"} curl "$ZUBA_API/v1/webhooks/123e4567-e89b-12d3-a456-426614174000/deliveries" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" ``` ```json theme={"dark"} { "deliveries": [ { "id": "...", "eventId": "evt_...", "eventType": "payout.paid", "httpStatusCode": 200, "attemptNumber": 1, "status": "delivered", "createdAt": "2026-06-30T10:02:00.000Z" } ], "nextCursor": null, "hasMore": false } ``` Zuba retries failed deliveries up to 11 times (12 attempts in total) with exponential backoff, over roughly 40 to 75 minutes; a delivery counts as successful on any `2xx` within 30 seconds. See [Delivery and Retries](/guides/webhooks#delivery-and-retries). ## Deposits `POST /v1/deposits` is simulated end to end in the sandbox. It accepts the same two routes as production, an `NGN` bank transfer (`method: "manual_deposit"`, the default for `NGN`) and a `KES` M-Pesa collection (`method: "mobile_money"`). No bank is contacted and no phone is prompted: the sandbox answers with synthetic payment instructions and settles the deposit itself a few seconds later. Nothing needs to be paid into the returned account. ### Create a deposit An `NGN` bank transfer returns `status: "pending"` and an account to pay into. `iban` carries the account number, `bic` the bank name and `reference` the deposit's payment reference. The `metadata` block holds the same details plus the account name and an `expiresAt`, as in production: ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/deposits" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clientRef": "dep-ngn-001", "amount": "5000.00", "currency": "NGN", "firstName": "Ada", "lastName": "Obi", "email": "ada@example.com", "phoneNumber": "+2348012345678" }' ``` ```json theme={"dark"} { "id": "123e4567-e89b-12d3-a456-426614174000", "method": "manual_deposit", "amount": "5000.00", "currency": "NGN", "status": "pending", "iban": "9912345678", "bic": "Zuba Sandbox Bank", "reference": "sandbox-123e4567-e89b-12d3-a456-426614174000", "metadata": { "providerReference": "sandbox-123e4567-e89b-12d3-a456-426614174000", "accountNumber": "9912345678", "accountName": "Zuba Sandbox Collections", "bankName": "Zuba Sandbox Bank", "expiresAt": "2026-10-14T10:00:00.000Z" }, "createdAt": "2026-10-13T10:00:00.000Z", "updatedAt": "2026-10-13T10:00:00.000Z" } ``` A `KES` M-Pesa collection returns `status: "processing"` and a `description` asking the payer to approve the prompt on their phone. As in production, the amount must be whole shillings and `phoneNumber` a Kenyan mobile number with the `254` country code. A fractional amount or a non-Kenyan number returns `400` with the reason, the deposit is `failed`, and `deposit.failed` is delivered. In production a collection also needs an active `KES` virtual account for the workspace or sub-account it is created for; the sandbox does not check this. In production M-Pesa collections must be enabled for your workspace, and until they are a `KES` M-Pesa create returns `403` with `METHOD_NOT_ENTITLED`; the sandbox has them enabled. ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/deposits" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clientRef": "dep-kes-001", "amount": "300", "currency": "KES", "method": "mobile_money", "phoneNumber": "+254712345678" }' ``` ### Settlement and the failure number About 5 seconds after it is created, the deposit reaches a terminal state. One magic value makes it fail: a `KES` M-Pesa collection from the payer number ending `01` in the Kenyan row of the [mobile money magic numbers](#mobile-money). | Deposit | Outcome | | - | - | | `KES` M-Pesa collection from `+254731234001` (also written `254731234001`) | `failed`. Nothing is credited, and `deposit.failed` is delivered. | | Any other `KES` M-Pesa collection, and every `NGN` bank transfer | `completed`. The amount is credited to the balance the deposit was created for (the sub-account named in `Zuba-Account-Id`, or your master workspace), and `deposit.completed` is delivered. | Created this way, a collection from any other Kenyan magic number, ending `00` or `02` to `05`, completes like any other number: only the `01` number selects a failure. The [deposit simulator](#step-3-trigger-a-deterministic-payout) reads the magic account numbers differently: there every number whose payout ends `failed` records a failed deposit. An `NGN` deposit created here always completes: no sandbox value makes an `NGN` deposit from `POST /v1/deposits` fail. To exercise your `deposit.failed` handling, use the `KES` failure number above, or send a failing magic account number to the deposit simulator as described in [Step 3](#step-3-trigger-a-deterministic-payout). An `NGN` deposit passes through `processing` on its way to `completed`. The sandbox charges no deposit fee, so `serviceFee` is `"0.00000000"` and `creditedAmount` equals `amount`. Follow the deposit with the `deposit.completed` and `deposit.failed` webhooks (see [Webhook Notifications](/guides/webhooks)) or by polling `GET /v1/deposits/:id`. The delay is a sandbox convenience: in production a deposit settles when the payer's money arrives, so do not build against a fixed delay. ## Virtual accounts Virtual-account issuance is fully simulated in the sandbox: `POST /v1/virtual-accounts` never opens a real bank account and never depends on any downstream banking service being available. Every currency below is issued to every approved sandbox workspace, whatever its country of incorporation, and the create call returns `201` with `status: "active"` and the account details already filled in. The returned details are synthetic: IBANs carry valid check digits so your format validation passes, and every simulated IBAN contains the marker `SNDB` so it is recognisable at a glance. No real bank transfer can reach them; simulate one with the [deposit simulator](#step-1-fund-your-workspace), as described in [Deposits to a virtual account](#deposits-to-a-virtual-account) below. | Currency | Identifiers you receive | Notes | | - | - | - | | `NGN` | `accountNumber` | Master workspace only. | | `USD` | `accountNumber` and `routingNumber` (domestic wire) | In production the account is reviewed before it opens; see below. | | `EUR` | `iban` (SEPA) | | | `GBP` | `accountNumber` and `sortCode` (Faster Payments) | | | `XOF`, `XAF`, `GHS` | `accountNumber` (IBAN) | Pass `country` (ISO 3166-1 alpha-2): one currency spans several countries. | | `KES` | `accountNumber` | | | `ZAR` | `accountNumber` and `paymentReference` | In production several accounts share one account number and a transfer must carry the `paymentReference`; the sandbox gives each account its own number, so a simulated deposit is matched on the number alone. | Any other currency returns `422`. The sandbox issues every currency in the create response, and still delivers the `virtual_account.active` webhook for each account it issues, so your completion handler is exercised. In production, every currency except `NGN` opens asynchronously: the create call returns `201` with `status: "pending"`, the account activates once it has been opened, and the `virtual_account.active` webhook is the completion signal (see [Webhooks](/guides/webhooks#virtual-account-events)). A `ZAR` account can also open inside the request, returning `201` with `status: "active"`. Build against that webhook rather than the timing you see in the sandbox: on activation, fetch the issued details with `GET /v1/virtual-accounts`, and if you must poll instead, don't assume a fixed delay. `USD` is issuable in production, but it is reviewed there: the account can stay `pending` for days, and a review that refuses it delivers `virtual_account.failed`. The sandbox opens it as the call is made. Issuance requires an approved workspace application and opens the account under your workspace's legal name; a sandbox workspace created through quick-create satisfies this for every issuable currency. To issue an account owned by a sub-account, send its id in the `Zuba-Account-Id` header; deposits to that account then settle into the sub-account's balance. `NGN` is the exception: those accounts are always held by the master workspace, so an `NGN` request naming a sub-account returns `422`. ### Deposits to a virtual account To simulate money arriving at an issued account, call the deposit simulator with the account's `accountNumber` (or its `iban`, with or without spaces) instead of a `currency`: ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/sandbox/deposits" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "amount": "250000.00", "accountNumber": "9123456789" }' ``` The account decides where the money lands, as a real transfer to it would. The deposit takes the account's currency and credits the account's owner: an account issued to a sub-account credits that sub-account. The `Zuba-Account-Id` header does not choose the owner, although a header you send must still name an account you own. The deposit records the account it arrived at, under the `method` a real transfer to that account records in production: `bank_deposit` for most accounts, and `virtual_account` for some `NGN` accounts, depending on the bank that serves them. Match on both values, as you would in production. It follows the same lifecycle as a direct top-up; `narration` works the same way, so a settlement reference in it is matched automatically. | Account number sent | Response | | - | - | | An active account of your workspace | `201`, deposit `processing`, then `completed` | | An account of your workspace that cannot receive deposits (not `active`, or not `enabled`) | `422` `virtual_account_not_active` | | Any other number, including another workspace's account | `404` `virtual_account_not_found` | A deposit to an `XOF`, `XAF` or `GHS` account is credited straight away in the sandbox, without the proof-of-payment step production uses for those accounts. A simulated deposit books no deposit fee, so your balance moves by the whole `amount` even where a real transfer to the same account is charged one in production. A deposit without `accountNumber` models money that reached your workspace off-platform, as an operator-credited deposit does in production, and records `method: "admin_deposit"`. A deposit to a magic account number records the same method. ### Simulating a rejection Some currencies' accounts are subject to an approval step that can refuse the account. To exercise your rejection handling, use a workspace whose **legal name contains `SANDBOX-REJECT`** (matched case-insensitively, e.g. `Acme SANDBOX-REJECT Ltd`). Issuance for that workspace is refused instead of approved: for every currency, the create call fails with a `502` provider error and no account is created, so a repeat create is refused the same way. Any other legal name is always approved. In production a refusal of an `XOF`, `XAF`, `GHS`, `USD` or `ZAR` account can also arrive after the create call has returned it `pending`: the approval workflow rejects it, a `virtual_account.failed` webhook fires, and the account never becomes active and never appears in `GET /v1/virtual-accounts`. A repeat create for that currency then answers with the refusal that stands. For `XOF`, `XAF` and `GHS` that is `409 VIRTUAL_ACCOUNT_DEACTIVATED`, while the refused account is parked for operator review; for `USD` and `ZAR` it is `409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED` (see [Webhooks](/guides/webhooks#virtual-account-events)). A `ZAR` refusal that asks you to retry after ten minutes is the exception to the account never becoming active: repeating the request then opens the account again with a new `paymentReference`. Handle both shapes: the synchronous `502` and the asynchronous `virtual_account.failed`. ## Quotes Quotes price a currency pair before you commit to it. `POST /v1/quotes` mints a single-use held quote and locks the rate until `expiresAt`, in the sandbox as in production. The `intent` field decides which executor can consume it. There are no magic values here. A sandbox quote is priced at the latest synced market rate and never calls a liquidity provider, and minting one is free, so you can also use the endpoint standalone for rate discovery and simply let unused quotes expire. A convert or order quote, or a payout quote from NGN, is refused with `422 INSUFFICIENT_LIQUIDITY` when a rate it prices from has not synced. ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/quotes" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "intent": "convert", "fromCurrency": "USD", "toCurrency": "EUR", "fromAmount": "500.00" }' ``` Every intent returns the same shape: the pair, the amount received, the total debited, the all-in rate, and the expiry. ```json theme={"dark"} { "id": "550e8400-e29b-41d4-a716-446655440000", "fromCurrency": "USD", "toCurrency": "EUR", "targetAmount": "455.1200", "totalDebitAmount": "500.0000", "rate": "1.09860000", "expiresAt": "2026-07-09T12:00:30Z" } ``` The `rate` is expressed in `fromCurrency` units per one unit of `toCurrency`, markup included, so `totalDebitAmount ≈ targetAmount × rate`: here `455.12 EUR × 1.0986 ≈ 500 USD`. A convert quote always returns `fee` and `feeCurrency` (`fee` is `"0.0000"` when your pricing carries no per-conversion fee); the fee is part of `totalDebitAmount`, so the amount that converts at `rate` is the debit less the fee. Convert pairs are directed. The platform converts between USD, GBP and EUR, between any of those and USDT or USDC, between USDT and USDC, and between NGN and USDT or USDC in either direction (NGN does not convert to USD, GBP or EUR). Your account is granted a subset of these: `GET /v1/conversions/corridors` lists exactly the pairs you can quote. An NGN pair is priced for the amount you request, so a very large amount can be refused with `INSUFFICIENT_LIQUIDITY`; try a smaller amount. In the sandbox an NGN pair is priced at the synced rate whatever the amount (an on-ramp still nets the flat network fee described below), and is refused with `INSUFFICIENT_LIQUIDITY` above 1,000,000 USDT. An NGN on-ramp (NGN into USDT or USDC) nets a flat network fee off what you receive, so a very small amount is refused with `AMOUNT_BELOW_FEE`; its `details.feeCostSource` is the NGN amount that fee costs at the current rate, and the conversion must exceed it. Every amount on a convert quote is a whole number of the currency's smallest unit: cents for USD, GBP and EUR, kobo for NGN, and four decimal places for USDT and USDC. A `fromAmount` in USD, GBP, EUR or NGN finer than its smallest unit is refused with `AMOUNT_PRECISION_EXCEEDED`, whose `details.decimalPlaces` is the number of places `fromCurrency` admits; any amount finer than four places is refused by request validation. While the account that holds your cash for one side of the pair is frozen, the quote and the conversion are refused with `422 ACCOUNT_NOT_ACTIVE` and nothing is debited. While your balance in one of the pair's currencies is held in more than one place, the conversion cannot be attributed to one of them, so the quote and the conversion are refused with `422 FUNDING_ACCOUNT_AMBIGUOUS`; contact support to resolve it. Which side of the pair you fix depends on the intent, and each intent is consumed by exactly one executor: | `intent` | Amount field | Consumed by | | - | - | - | | `payout` | `toAmount` (the beneficiary amount) or `fromAmount` (the debit before the payout fee), exactly one | `POST /v1/payouts`, as `quoteId` | | `convert` | `fromAmount` (the source debit) | `POST /v1/conversions`, as `quoteId` | | `order` | `fromAmount` or `toAmount`, exactly one | `POST /v1/orders`, as `quotationId` | Three rules apply to every quote: * **Single-use.** Creating the downstream conversion, order, or payout consumes the quote; a second use is rejected with `QUOTE_ALREADY_USED`. * **Short-lived.** Expiry is typically well under a minute; always read `expiresAt` rather than assuming a TTL, and re-quote if it lapses. * **The intent is binding.** Feeding a convert quote to `POST /v1/orders` (or any other mismatch) is rejected with `QUOTE_INTENT_MISMATCH`. Check on a quote at any time with `GET /v1/quotes/{id}`, which adds a `status` of `active`, `used`, or `expired`. ### Feed a quote into a conversion With the USD balance from [Step 1](#step-1-fund-your-workspace), execute an in-wallet conversion by consuming the convert quote above: ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/conversions" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "quoteId": "550e8400-e29b-41d4-a716-446655440000" }' ``` Conversions settle inline: the response is the conversion in its terminal state (normally `completed`), with the source debit and target credit already applied to your balances. Confirm with `GET /v1/ledger/balances`. ### Feed a quote into an order Every order starts from a quote with `intent: "order"`; there is no auto-priced path. Mint the quote with `fromAmount` or `toAmount` fixed, then create the order with the returned id as `quotationId` and a beneficiary holding an active account in the buy currency. The flow is covered end-to-end in [Trade Desk Orders](/guides/orders); the sandbox twist is the magic sell amounts below. ### Deterministic orders Orders are executed manually by the trade desk, so in the sandbox two magic **sell amounts** short-circuit the desk and drive an order straight to a terminal state. The match is on the order's source (sell) amount, is numeric (`1111.11` matches `1111.1100`), and works in any sell currency: | Sell amount | Outcome | | - | - | | `1111.11` | The order runs the full desk flow and lands `completed` (with a `sandbox-…` UETR, or for a delivery to a crypto wallet a `txHash` well-formed for the wallet's network that names no real transaction). | | `2222.22` | The order is rejected by the desk and lands `failed` (`execution_failed`), with the held funds returned to your balance. | > [!NOTE] > > In a sell currency with no minor unit — XOF, XAF, JPY, KRW, VND, UGX — the triggers are the whole units **`1111`** and **`2222`**. A quote holds its amounts at the sell currency's own precision, so `1111.11` XOF is quoted as `1111` and reaches the desk as the whole-unit trigger. Sending either form gives the same outcome; reading `totalDebitAmount` back off the quote tells you which value the order will carry. Both fire the full webhook sequence (`order.created`, `order.funds_received`, then `order.completed` or `order.failed`). The magic amounts apply on either funding path: a balance-funded order short-circuits at creation, and a settlement-funded order short-circuits the moment its deposit matches (so the sequence gains `order.awaiting_funds` between `order.created` and `order.funds_received`). Any other amount stays in `funds_received` awaiting desk execution, exactly as in production. To pin the sell amount precisely, fix `fromAmount` on the order quote: ```bash theme={"dark"} curl -X POST "$ZUBA_API/v1/quotes" \ -H "Authorization: Bearer $ZUBA_TEST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "intent": "order", "fromCurrency": "USDT", "toCurrency": "NGN", "fromAmount": "1111.11" }' ``` ### Feed a quote into a payout Mint with `intent: "payout"`, fixing either `toAmount` to the beneficiary amount or `fromAmount` to what you want debited, then pass the id as `quoteId` on `POST /v1/payouts` with `amount` set to the quote's `targetAmount`. On a cross-currency pair, a quote fixed by `fromAmount` rounds `targetAmount` down to the smallest unit every payout method in that currency accepts (whole shillings for KES); on a same-currency pair the two are equal. The payout fee is added to the debit when the payout is created. NGN to USD, EUR and GBP quotes take `toAmount` only. Some cross-currency corridors require a quote; others are auto-priced at creation when `quoteId` is omitted. The magic beneficiary values from [Step 3](#step-3-trigger-a-deterministic-payout) drive the outcome exactly as for an auto-priced payout, so a quote-first payout can still land a guaranteed `paid` or `failed`. ## Tips * **Use distinct `clientRef` values per test run** so polling and webhook handlers can correlate requests cleanly. * **Don't assume status transitions are instant.** The create response returns `created`; both the move to `processing` and the terminal transition happen asynchronously. Tests that expect immediate state change will be flaky: wait on the webhook or poll the GET endpoint. * **Test failures too.** It's easy to verify the happy path; make sure your error-handling code is exercised by the documented failure values as well. * **The beneficiary identifier is what matters.** Only the identifier field (`crAccount` for NGN/GHS/KES/UGX/XAF/XOF, `accountNumber` for USD and the sandbox-only ZMW/MWK/EGP, `iban` for EUR/GBP/CHF with the [magic IBANs](#iban-payouts-eur-gbp-and-chf), `phoneNumber` for mobile money) is matched; the bank code, routing number, BIC, and mobile provider are free to be any valid value. * **Test values only behave deterministically in the sandbox.** In production they are subject to normal account validation and will not produce these outcomes. ## Next steps * [Webhook Notifications](/guides/webhooks): endpoint setup, signature verification, retries, and idempotency * [Your First Payout](/guides/first-payout): beneficiaries, routes, and the payout request shape * [Error Handling](/guides/error-handling): robust handling of failure paths # Webhook Notifications Source: https://docs.zuba.com/guides/webhooks Set up real-time notifications for payment events using webhooks Webhooks provide real-time notifications about payment events, allowing your application to respond immediately to payment completions, failures, and status changes. This guide covers implementing secure webhook endpoints. ## Why Use Webhooks * **Real-time updates**: Immediate notification when payouts and orders complete * **Reliable delivery**: Built-in retry mechanisms for failed deliveries * **Reduced polling**: No need to constantly check payout status * **Better UX**: Instant confirmation and account updates ## Webhook Events Zuba sends webhooks for the following events: ### Payout Events * `payout.processing` - Payout is being processed by the payment provider * `payout.paid` - Payout completed successfully * `payout.failed` - Payout failed before settlement * `payout.reverted` - A payout that had already settled (`payout.paid`) was later rejected by the provider's downstream bank; an operator manually refunded the merchant and the amount was credited back. The payout fee may not come back with the amount: the payment was executed on the details given, as with any bank transfer the receiving bank bounces, so `feeRefunded` on the payout (`GET /v1/payouts/{id}`) says whether the fee was refunded. Unlike `payout.failed` (a pre-settlement failure, which refunds amount and fee), this payout did originally succeed: do **not** automatically re-issue it. * `payout.cancelled` - Payout was cancelled, whether you cancelled it via `POST /v1/payouts/{id}/cancel` or it was cancelled downstream. The debited balance is returned to your account as part of the cancellation; confirm it from your balance rather than from this event A status Zuba's operations team sets on your payout through its operations tools, for example marking it paid once the provider confirms a payment it could not report, is announced with the same events as any other change. That includes moving a failed payout to paid (`payout.paid`) or to cancelled (`payout.cancelled`). A cancellation that follows a failure completes a balance return the failure did not finish and returns nothing the failure already returned; a fee kept on a returned payout stays kept. Confirm it from your balance. #### Payout Lifecycle Where each event fires in a payout's life. `created` and `queued` are internal pre-processing states and fire **no webhook**. The first delivery you receive is normally `payout.processing`, but a payout that fails after it is created and before processing emits `payout.failed` as its first and only event. A payout funded from your own account at the bank holding a managed deposit account can also fail early. For a cross-currency payout, and for a USD-to-USD SWIFT wire sent without a quote, those funds are checked and drawn when the payout is created. If the bank declines to release them, the call returns `201` with the payout `failed`. If the account is frozen, unreachable or held at more than one institution, the payout is refused: sent without a quote it returns `201` with the payout `failed`, and sent with a `quoteId` it is refused with a `422` before any payout is created. A USD-to-USD SWIFT wire that the bank holding your managed deposit account delivers itself (to a receiving bank outside the US, on a corridor that bank serves) is not refused at creation when its funds are at that bank or at more than one institution. Any other same-currency payout from such an account is created as usual and, if its funds cannot be drawn, fails before it is sent, with its funds returned. A request refused before a payout is created, such as a submit-time validation error or a payout your balance cannot cover (a `400` whose `code` is `INSUFFICIENT_BALANCE`), emits no webhook: ```mermaid theme={"dark"} %%{init: { "theme": "base", "themeVariables": { "fontFamily": "'Hanken Grotesk', system-ui, sans-serif", "fontSize": "13px", "primaryColor": "#fbf4ea", "primaryBorderColor": "#d8c8ad", "primaryTextColor": "#323236", "lineColor": "#a99e8c", "tertiaryColor": "#efe3d2", "tertiaryBorderColor": "#d8c8ad", "tertiaryTextColor": "#5b5347" } }}%% stateDiagram-v2 direction LR state "created / queued" as pending state "processing" as processing [*] --> pending : POST /v1/payouts (no webhook) pending --> processing : payout.processing pending --> failed : payout.failed (failure before dispatch) processing --> paid : payout.paid processing --> failed : payout.failed pending --> cancelled : payout.cancelled processing --> cancelled : payout.cancelled paid --> failed : payout.reverted (settled, then returned) failed --> paid : payout.paid (set by Zuba's operations team) failed --> cancelled : payout.cancelled (set by Zuba's operations team) paid --> [*] failed --> [*] cancelled --> [*] ``` > [!NOTE] > > The decline reason is not included in the `payout.failed` webhook payload. > When you receive one, fetch `GET /v1/payouts/{id}` and read the > `failureReason` field. ### Order Events Orders (`POST /v1/orders`) are executed manually by Zuba's trade desk; webhooks are how you learn the outcome. See [Trade Desk Orders](/guides/orders) for the full lifecycle. * `order.created` - The order was created. Fires together with `order.funds_received` (balance-funded) or `order.awaiting_funds` (settlement-funded) * `order.awaiting_funds` - Funding instructions were issued; the payload's `settlement` block carries the account or address, amount, and reference to fund the order * `order.funds_received` - The source amount is held and the order is queued for desk execution * `order.completed` - The desk delivered to your beneficiary; `uetr` is present for wire deliveries, `txHash` for deliveries to a crypto wallet * `order.failed` - The order failed (`failureReason` set) and the held funds were returned to your balance * `order.expired` - The funding window closed before the order was funded; a transfer arriving later credits the balance instead There is no `order.cancelled` event (cancellation is answered synchronously by `POST /v1/orders/{id}/cancel`) and no event for the `executing` status (desk review is internal). ### Account Events * `account.created` - An account record was created via `POST /v1/accounts` ### Application Events Your onboarding application moves through review out of band, so these events report where it stands. * `application.under_review` - The application was submitted and is being reviewed * `application.more_info_requested` - Review needs more from you; the payload adds `moreInfoReason` and the `editableSections` to complete * `application.approved` - The application was approved * `application.rejected` - The application was rejected ### Virtual Account Events Most currencies issue virtual accounts asynchronously: `POST /v1/virtual-accounts` returns `status: "pending"` and the account completes out of band, which can take far longer than a request cycle. These events are the completion signal, so use them rather than a polling loop. A failed delivery can be re-sent (see [Redelivering a failed delivery](#redelivering-a-failed-delivery)), but no event exists for an account that has not completed, so if you have been waiting longer than you expect, repeat the `POST`: it is idempotent per owner and currency, and answers with the pending account, the active one, or the refusal that stands. `GET /v1/virtual-accounts` lists active accounts only, so a pending account and a refused one are both simply absent from it. A refusal answers on the `POST` itself when it can be settled inside the request. `409 KYB_INCOMPLETE` means the account owner's own approved application is not complete enough to open the account, and the message names what is outstanding — a document still to upload, or an acceptance of the terms the account would be opened under that the owner has not given at the version currently in force. `GET /v1/virtual-accounts/requirements` reports the same set before you request anything, and for acceptance it hands back the documents themselves. `409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED` is the other half: the provider requires verification material beyond the application's fields. Complete the outstanding onboarding verification and repeat the `POST` — the request is refused with the same code until the verification passes. A repeat within ten minutes of the last refusal is answered from that refusal without consulting the provider, so allow that interval before retrying. Where the message says the account was taken out of service, there is nothing to complete: repeating the `POST` once the ten minutes have passed issues a new account. `409 VIRTUAL_ACCOUNT_DEACTIVATED` is different: the account was deactivated, and only support can re-issue it. * `virtual_account.active` - The account was approved and now carries its issued details; fetch them with `GET /v1/virtual-accounts` * `virtual_account.failed` - The account was refused after issuance, or can never produce usable payment details. A repeat `POST /v1/virtual-accounts` for the same currency answers with the refusal that stands (`409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED` for a provider verification refusal, `409 VIRTUAL_ACCOUNT_DEACTIVATED` for a deactivation only support can lift) Which events a currency can fire follows from where its approval step sits: * `XOF`/`XAF`/`GHS` accounts are approved **after** creation, so a refusal lands on an account you already hold as `pending` and fires `virtual_account.failed`. * `ZAR` accounts are opened by the bank partner, either inside the request (`201` with `status: "active"` and the details filled in) or **after** it (`201` with `status: "pending"`). A `POST` the partner answers with an account already out of service answers `409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED`; a `POST` that fails at the partner answers `502` and is safe to repeat. Once a `POST` has answered `pending`, `virtual_account.active` carries the issued details, and `virtual_account.failed` means the partner refused the account; a repeat request then answers `409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED` while the refusal stands. Where the refusal asks you to retry after ten minutes, repeating the request then opens the account again with a new `paymentReference`; otherwise contact support. * `EUR`/`GBP` accounts are approved **before** creation: a refusal surfaces as an error on `POST /v1/virtual-accounts` itself. Expect `virtual_account.active` only; these two currencies emit no `virtual_account.failed`, so an account that stays `pending` far longer than usual needs support rather than a retry. * `USD` accounts are verified **after** creation, and part of the verification can also settle inside the request: a `POST` the provider refuses outright answers `409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED` with nothing created — complete the outstanding verification and repeat the request. A `POST` refused `409 KYB_INCOMPLETE` never reached the bank at all: the named document or acceptance is still owed on the owner's application, and supplying it makes the repeat succeed. Once the `POST` answers `201` with `status: "pending"`, the remaining verification can run for days: `virtual_account.active` carries the issued wire details, and `virtual_account.failed` means the verification refused the account — a repeat request then answers `409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED` while the refusal stands; where the refusal names verification material you can supply, completing it and repeating the request is the recovery, otherwise contact support. When `accountId` is not null the account belongs to that sub-account, and both follow-up calls are owner-scoped: send `Zuba-Account-Id: ` on the `GET` or the repeat `POST`, or the request addresses your master account's accounts instead. The payload identifies the account but never carries bank details — always read those from the authenticated `GET /v1/virtual-accounts`. `NGN` accounts are held by your master account: `POST /v1/virtual-accounts` issues one for the master, and a request naming a sub-account answers `422 VIRTUAL_ACCOUNT_SELF_SERVE_UNSUPPORTED`. NG-incorporated workspaces also have one provisioned during onboarding review and read it from `GET /v1/virtual-accounts` once the application is approved. In production an `NGN` account that opens within the request emits no event; one that opens only after a review emits `virtual_account.active` when it opens, or `virtual_account.failed` if the review refuses it. In the sandbox, `POST /v1/virtual-accounts` returns the `NGN` account number directly, already `active`, and `virtual_account.active` follows as it does for every sandbox account. ### Deposit Events Incoming deposits notify on their terminal outcome, so you can credit your customer (or surface a failure) without polling `GET /v1/deposits`. * `deposit.completed` - Funds were received and credited to your Zuba balance. The balance moves by `creditedAmount`: `amount` less `serviceFee`. Credit your own customer from that figure, not from `amount`. Fires once when the deposit reaches `completed`, on any rail — a bank transfer or mobile-money/paybill credit into a virtual account, an M-Pesa STK-push collection, a USD/EUR/GBP account credit, or an on-chain crypto deposit. A deposit held for compliance review (`in_review`) fires this only once it clears. * `deposit.failed` - The deposit terminally failed — an M-Pesa STK push the payer declined or entered the wrong PIN for, an inbound transfer returned before it ever credited, or a deposit rejected during compliance review (`in_review`). Usually nothing was credited and `creditedAmount` and `serviceFee` are both `null`, but a deposit rejected in review after its credit had already landed carries the figures that credit moved. Read `status` before you credit anyone from them. A deposit that already `completed` and is later reversed is not a `deposit.failed`. No webhook fires for `pending` or `processing`, and the refund lifecycle (`refunding`/`refunded`) is not yet delivered as a webhook. The payload identifies the deposit by `id` + `clientRef`, and carries the amount, plus the charge and the credit where the deposit credited; read full detail from `GET /v1/deposits`. ```json deposit.completed theme={"dark"} { "id": "evt_9f2c...", "type": "deposit.completed", "createdAt": "2026-09-08T09:24:31.000Z", "test": false, "data": { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "clientRef": "DEPOSIT-REF-002", "amount": "500.00000000", "currency": "KES", "serviceFee": "0.00000000", "creditedAmount": "500", "method": "mobile_money", "status": "completed", "accountId": null, "completedAt": "2026-09-08T09:24:31.000Z" } } ``` ```json deposit.failed theme={"dark"} { "id": "evt_a3d1...", "type": "deposit.failed", "createdAt": "2026-09-08T09:25:10.000Z", "test": false, "data": { "id": "9c8b7a65-4321-4def-8abc-1122334455ff", "clientRef": "DEPOSIT-REF-003", "amount": "500.00000000", "currency": "KES", "serviceFee": null, "creditedAmount": null, "method": "mobile_money", "status": "failed", "accountId": null, "completedAt": null } } ``` ### Other Events * `webhook.test` - Test event sent via the test endpoint ## Event Payload Every webhook delivery is a JSON POST request with this envelope: ```json theme={"dark"} { "id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "type": "payout.paid", "createdAt": "2026-03-23T14:30:00.000Z", "sequence": 3, "test": false, "data": { "id": "123e4567-e89b-12d3-a456-426614174000", "clientRef": "your-reference-123", "amount": "1000.00000000", "currency": "USD", "status": "paid", "completedAt": "2026-03-23T14:29:58.000Z", "failedAt": null } } ``` | Field | Type | Description | | - | - | - | | `id` | string | Unique event ID (`evt_` prefix). Every delivery attempt of the same event carries the same ID | | `type` | string | Event type | | `createdAt` | string | ISO 8601 timestamp of event creation. On `payout.*` events, when the status change happened | | `sequence` | integer | `payout.*` events only: the change's position in that payout's history, higher is later. See [Ordering](#ordering) | | `test` | boolean | `true` if sent from the test webhook endpoint | | `data` | object | Event-specific payload. On `payout.*` events, the payout as it stood at that change | ### Event-specific `data` fields **`payout.*` events** | Field | Type | Description | | - | - | - | | `id` | string (UUID) | Payout ID | | `clientRef` | string | Your reference from the create request | | `amount` | string | Major-unit decimal string. Trailing zeros are preserved up to 8 decimal places (e.g. `"1000.00000000"`). Compare as decimals, not strings | | `currency` | string | Currency code: ISO 4217, or a supported stablecoin ticker (`USDC`, `USDT`) for crypto payouts | | `status` | string | Payout status after the transition | | `completedAt` | string \| `null` | ISO 8601 settlement instant, set when the payout reaches `paid`. A payout that settled and was later returned keeps its original settlement instant here. It may also carry the terminal instant of a payout that failed without ever settling, so read `failedAt` for the failure itself; `null` while the payout is in flight and on `cancelled` | | `failedAt` | string \| `null` | ISO 8601 instant the payout reached `failed`. On a returned payout this is the return, while `completedAt` stays at the settlement; `null` unless the payout failed, and `null` again if a failed payout is later confirmed paid | **`order.*` events** | Field | Type | Description | | - | - | - | | `id` | string (UUID) | Order ID | | `clientRef` | string \| `null` | Your reference from the create request | | `status` | string | Order status after the transition | | `fromCurrency` | string | Sell (source) currency | | `toCurrency` | string | Buy (delivered) currency | | `fromAmount` | string | Total source debit, major-unit decimal string | | `toAmount` | string | Amount delivered, major-unit decimal string | | `rate` | string | All-in rate: `fromCurrency` per 1 `toCurrency` | | `failureReason` | string \| `null` | `execution_failed` or `review_expired`; set only on `order.failed` | | `uetr` | string \| `null` | Outbound wire reference; set on completion over a wire rail | | `txHash` | string \| `null` | On-chain transaction hash; set on completion of a delivery to a crypto wallet | | `settlement` | object \| `null` | Funding instructions for settlement-funded orders (`settlementId`, `method`, `requiredAmount`, `transferAmount`, `currency`, `reference`, `status`, `requirements.proofOfPayment`, `instructions`, `expiresAt`). Send `transferAmount`; `requiredAmount` is what must land; `null` on balance-funded orders. Upload evidence only when `requirements.proofOfPayment` is `true`; do not infer it from currency or method. | | `fundingDeadlineAt` | string | ISO 8601 funding deadline | | `executionDeadlineAt` | string \| `null` | ISO 8601 desk execution deadline, set once funds are received | | `fundedAt` | string \| `null` | ISO 8601 funding timestamp | | `completedAt` | string \| `null` | ISO 8601 completion timestamp | | `createdAt` | string | ISO 8601 creation timestamp | **`account.created`** | Field | Type | Description | | - | - | - | | `id` | string (UUID) | Account record ID | | `parentAccountId` | string (UUID) | The master account the record was created under | | `accountType` | string | `sub` for records created via `POST /v1/accounts` | | `status` | string | Account status (`pending` on create) | | `country` | string | ISO 3166 alpha-2 country code | | `createdAt` | string | ISO 8601 creation timestamp | **`application.*` events** | Field | Type | Description | | - | - | - | | `id` | string (UUID) | Application ID | | `accountId` | string (UUID) \| `null` | The sub-account the application belongs to; `null` for your master account | | `applicationType` | string | `business` or `individual` | | `status` | string | Application status after the transition | | `moreInfoReason` | string \| `null` | Why more information is needed; on `application.more_info_requested` only | | `editableSections` | string\[] | The sections you can edit and resubmit; on `application.more_info_requested` only | **`virtual_account.*` events** | Field | Type | Description | | - | - | - | | `id` | string (UUID) | Virtual account ID | | `accountId` | string (UUID) \| `null` | The sub-account that owns the virtual account; `null` when your master account owns it directly | | `currency` | string | ISO 4217 currency code | | `status` | string | `active` or `failed`. Bank details are never included — read them from `GET /v1/virtual-accounts` | **`deposit.*` events** | Field | Type | Description | | - | - | - | | `id` | string (UUID) | Deposit ID | | `clientRef` | string \| `null` | Your reference from the create request, when the deposit was created with one | | `amount` | string | What arrived, as a major-unit decimal string with trailing zeros preserved to 8 places (e.g. `"500.00000000"`). Compare as decimals, not strings | | `currency` | string | ISO 4217 currency code | | `serviceFee` | string \| `null` | The whole fee borne on this deposit: `amount` less `creditedAmount`. Where the rail the money arrived on takes its own cut first, that cut is inside this figure and is never itemised. On a deposit into one of your sub-accounts, any fee you charge on it yourself is inside this figure too, booked to your master account. `"0.00000000"` where a credit bore no fee, and `null` where the deposit credited nothing | | `serviceFeeCurrency` | string \| absent | Currency the fee was taken in. Present whenever a fee was borne; absent only where none was and the deposit was not priced, which is not the same as a fee of zero | | `creditedAmount` | string \| `null` | What your balance moved by on `deposit.completed`: `amount` less `serviceFee`. Credit your own customer from this, not from `amount`. `null` wherever the deposit credited nothing, which is the usual `deposit.failed` case; a failure that followed a landed credit still carries the figure, so read `status` before crediting anyone | | `method` | string | Collection method, e.g. `mobile_money`, `bank_transfer`, `crypto_deposit` | | `status` | string | `completed` or `failed` — the terminal status this event represents | | `accountId` | string (UUID) \| `null` | The sub-account credited; `null` when your master account was credited directly | | `completedAt` | string \| `null` | ISO 8601 credit instant on `deposit.completed`; `null` on `deposit.failed` (a failed deposit never completed) | Each request includes these headers: | Header | Description | | - | - | | `Content-Type` | `application/json` | | `X-Zuba-Signature` | HMAC-SHA256 hex signature | | `X-Zuba-Timestamp` | Unix timestamp (seconds) used in signature | ## Setting Up Webhook Endpoints Webhook endpoint URLs must use **HTTPS**: registering an `http://` URL is rejected with a `400`. ### Basic Webhook Handler Create an endpoint to receive webhook notifications: ```typescript theme={"dark"} import express from 'express'; import crypto from 'crypto'; const app = express(); // Use raw body parser for webhook signature verification app.use('/webhooks', express.raw({ type: 'application/json' })); app.post('/webhooks/zuba', async (req, res) => { const signature = req.headers['x-zuba-signature']; const timestamp = req.headers['x-zuba-timestamp']; const payload = req.body.toString(); // Verify webhook signature if (!verifyWebhookSignature(payload, signature, timestamp)) { return res.status(401).json({ error: 'Invalid signature' }); } const event = JSON.parse(payload); try { await processWebhookEvent(event); res.status(200).json({ received: true }); } catch (error) { console.error('Webhook processing failed:', error); res.status(500).json({ error: 'Processing failed' }); } }); ``` ### Processing Different Event Types Handle specific webhook events: ```typescript theme={"dark"} async function processWebhookEvent(event) { console.log(`Processing webhook: ${event.type}`); switch (event.type) { case 'payout.paid': await handlePayoutCompleted(event.data); break; case 'payout.failed': await handlePayoutFailed(event.data); break; default: console.log(`Unhandled event type: ${event.type}`); } } // Handle payout completion async function handlePayoutCompleted(payload) { const payoutId = payload.id; // Update payout status await updatePayoutStatus(payoutId, 'completed'); // Notify the beneficiary await notifyBeneficiary(payoutId); // Update accounting records await recordPayoutCompletion(payoutId, payload.amount); console.log(`Payout completed: ${payoutId}`); } ``` ## Webhook Security ### Verifying Signatures Every webhook delivery includes a cryptographic signature so you can verify the request genuinely originated from Zuba and has not been tampered with. **You should always verify signatures before processing webhook events.** The signature is computed as: ``` HMAC-SHA256(signing_secret, timestamp + "." + raw_body) ``` Where: * **`signing_secret`** is the plaintext secret (the `whsec_`-prefixed value returned when you created the endpoint) * **`timestamp`** is the value from the `X-Zuba-Timestamp` header * **`raw_body`** is the raw HTTP request body (not parsed JSON) * The result is hex-encoded (64 characters) > [!WARNING] > > Your signing secret is only shown once when you create a webhook endpoint or > rotate the secret. Store it securely. If you lose it, use the rotate secret > endpoint to generate a new one. #### Verification Steps 1. Extract the `X-Zuba-Signature` and `X-Zuba-Timestamp` headers 2. Concatenate the timestamp, a literal `.`, and the raw request body 3. Compute the HMAC-SHA256 of that string using your signing secret 4. Compare your computed signature against the header value using a **constant-time comparison** 5. Check that the timestamp is recent (within your tolerance window) to prevent replay attacks #### Code Examples ```javascript Node.js theme={"dark"} import crypto from 'crypto'; function verifyWebhookSignature(payload, signature, timestamp) { const secret = process.env.ZUBA_WEBHOOK_SECRET; if (!secret || !signature || !timestamp) { return false; } // Reject timestamps older than 5 minutes const currentTime = Math.floor(Date.now() / 1000); if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300) { return false; } const expectedSignature = crypto .createHmac('sha256', secret) .update(`${timestamp}.${payload}`) .digest('hex'); try { return crypto.timingSafeEqual( Buffer.from(expectedSignature, 'hex'), Buffer.from(signature, 'hex'), ); } catch { // timingSafeEqual throws if the buffers differ in length (malformed header) return false; } } ``` ```python Python theme={"dark"} import hmac import hashlib import time def verify_webhook_signature(raw_body: bytes, headers: dict, secret: str) -> bool: signature = headers.get('x-zuba-signature', '') timestamp = headers.get('x-zuba-timestamp', '') if not signature or not timestamp: return False # Reject timestamps older than 5 minutes current_time = int(time.time()) if abs(current_time - int(timestamp)) > 300: return False signed_content = f"{timestamp}.{raw_body.decode('utf-8')}" expected_signature = hmac.new( secret.encode('utf-8'), signed_content.encode('utf-8'), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected_signature, signature) # Flask example from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/webhooks/zuba', methods=['POST']) def handle_webhook(): raw_body = request.get_data() if not verify_webhook_signature(raw_body, request.headers, WEBHOOK_SECRET): return jsonify({'error': 'Invalid signature'}), 401 event = request.get_json(force=True) # Process event... return jsonify({'received': True}), 200 ``` ```go Go theme={"dark"} package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" "math" "net/http" "strconv" "time" ) func verifyWebhookSignature(rawBody []byte, header http.Header, secret string) bool { signature := header.Get("X-Zuba-Signature") timestamp := header.Get("X-Zuba-Timestamp") if signature == "" || timestamp == "" { return false } // Reject timestamps older than 5 minutes ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if math.Abs(float64(time.Now().Unix()-ts)) > 300 { return false } signedContent := fmt.Sprintf("%s.%s", timestamp, string(rawBody)) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signedContent)) expectedSignature := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expectedSignature), []byte(signature)) } func webhookHandler(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } if !verifyWebhookSignature(rawBody, r.Header, webhookSecret) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // Process event... w.WriteHeader(http.StatusOK) w.Write([]byte(`{"received": true}`)) } ``` ```java Java theme={"dark"} import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; public class WebhookVerifier { private final String secret; public WebhookVerifier(String secret) { this.secret = secret; } public boolean verify(String rawBody, String signature, String timestamp) { if (signature == null || timestamp == null) { return false; } // Reject timestamps older than 5 minutes long currentTime = System.currentTimeMillis() / 1000; if (Math.abs(currentTime - Long.parseLong(timestamp)) > 300) { return false; } try { String signedContent = timestamp + "." + rawBody; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hash = mac.doFinal( signedContent.getBytes(StandardCharsets.UTF_8)); String expectedSignature = bytesToHex(hash); return MessageDigest.isEqual( expectedSignature.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8)); } catch (Exception e) { return false; } } private static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(bytes.length * 2); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } } ``` #### Replay Protection The timestamp is included in the signed content specifically to enable replay protection. An attacker who intercepts a valid webhook cannot replay it at a later time if you enforce a timestamp tolerance. We recommend rejecting any webhook where the `X-Zuba-Timestamp` is more than **5 minutes** from your server's current time. All the examples above implement this check. > [!NOTE] > > Make sure your server's clock is synchronized with NTP. A clock that drifts > significantly could cause valid webhooks to be rejected. ### IP Allowlisting Zuba does not currently publish a static egress IP range for webhook deliveries. Signature verification (above) is the supported way to authenticate incoming webhooks: it proves both origin and integrity, which an IP check alone does not. ## Handling Webhook Failures ### Idempotency Zuba delivers each event at least once, so the same event can reach you more than once. Every delivery of an event carries the same `id`: record the IDs you have processed and skip any you have seen. ```typescript theme={"dark"} const processedWebhooks = new Set(); // In production, use Redis or database async function processWebhookEvent(event) { const eventId = event.id || `${event.type}-${event.createdAt}`; // Check if already processed if (processedWebhooks.has(eventId)) { console.log(`Webhook ${eventId} already processed, skipping`); return; } try { // Process the event await handleEvent(event); // Mark as processed processedWebhooks.add(eventId); console.log(`Webhook ${eventId} processed successfully`); } catch (error) { console.error(`Failed to process webhook ${eventId}:`, error); throw error; } } ``` ### Ordering Deliveries can arrive out of order: a `payout.paid` can reach you before the `payout.processing` that preceded it. Each `payout.*` event's `data` is the payout as it stood at that change, not when it was delivered, so apply a payout event only if its `sequence` is higher than the last one you applied for that payout, and ignore it otherwise. To read a payout's current state, call `GET /v1/payouts/{id}`. ### Retry Logic Implement exponential backoff for webhook processing: ```typescript theme={"dark"} class WebhookProcessor { constructor() { this.maxRetries = 3; this.baseDelay = 1000; // 1 second } async processWithRetry(event, attempt = 1) { try { await this.processEvent(event); } catch (error) { if (attempt < this.maxRetries) { const delay = this.baseDelay * Math.pow(2, attempt - 1); console.log(`Retry ${attempt} in ${delay}ms for event ${event.type}`); setTimeout(() => { this.processWithRetry(event, attempt + 1); }, delay); } else { console.error(`Max retries exceeded for event ${event.type}:`, error); // Send to dead letter queue or alert admin await this.handleFinalFailure(event, error); } } } async handleFinalFailure(event, error) { // Log to monitoring system console.error('Webhook processing failed permanently:', { event: event.type, eventId: event.id, error: error.message, }); // Optionally: send alert, save to DLQ, etc. } } ``` ### Delivery and Retries Zuba retries failed deliveries up to **11 times** (12 attempts in total) with exponential backoff, over roughly 40 to 75 minutes. A delivery is successful when your endpoint responds with a **2xx** status code within **30 seconds**. Redirects are not followed: a 3xx response counts as a failed attempt. | Attempt | Delay after the previous attempt | | - | - | | 1 | Immediate | | 2 | \~30 seconds | | 3 | \~30 seconds | | 4 | \~35 seconds | | 5 | \~40 seconds | | 6 | \~45 seconds | | 7 | \~1 minute | | 8 | \~1.5 minutes | | 9 | \~2.5 minutes | | 10 | \~5 minutes | | 11 | \~9 minutes | | 12 | \~18 minutes | Delays include random jitter and can be up to twice these values. Treat them as lower bounds. After all attempts are exhausted, the delivery is marked as failed. You can view delivery history via the API: ```bash theme={"dark"} curl "https://api.sandbox.zuba.com/v1/webhooks/{endpoint_id}/deliveries" \ -H "Authorization: Bearer $TOKEN" ``` To inspect a single delivery, including the exact payload that was sent, fetch `GET /v1/webhooks/{endpoint_id}/deliveries/{delivery_id}`. ### Redelivering a failed delivery Once your endpoint is fixed, re-send a delivery whose `status` is `failed`: ```bash theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}/redeliver" \ -H "Authorization: Bearer $TOKEN" ``` The delivery is sent again exactly as stored: the same event `id` and, for payout events, the same `sequence`, so your deduplication and ordering treat it as the event you missed. The signature and `X-Zuba-Timestamp` are fresh. The response is the delivery, now `pending`; it is retried on the schedule above, and its attempt numbers continue from the earlier attempts. An API key needs the `create:webhooks` scope. A delivery that is `pending` (still being retried) or `delivered` cannot be re-sent, and the request returns `409` with `WEBHOOK_DELIVERY_NOT_REDELIVERABLE`. A redelivery that succeeded makes the delivery `pending`, so a retry of the same request returns this `409` too; read the delivery to see its status. A delivery to a disabled endpoint returns `409` with `WEBHOOK_ENDPOINT_DISABLED`; enable the endpoint first. An endpoint or delivery outside your workspace returns `404`. > [!WARNING] > > Your endpoint must respond within 30 seconds. If you need to do long-running > processing, accept the webhook with a `200` response immediately and process > the event asynchronously. ## Testing Webhooks ### Sending a Test Event Trigger a `webhook.test` delivery to a registered endpoint: ```bash theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/webhooks/{endpoint_id}/test" \ -H "Authorization: Bearer $TOKEN" ``` The delivery carries `"test": true` in the envelope and is signed like any other event, so you can exercise your signature verification and processing end to end. ### Local Testing with ngrok Expose your local development server with [ngrok](https://ngrok.com/docs/getting-started): ```bash theme={"dark"} # Expose local server ngrok http 3000 # Use the HTTPS URL for webhook configuration # https://abc123.ngrok-free.app/webhooks/zuba ``` ## Complete Example ```typescript theme={"dark"} import express from 'express'; import crypto from 'crypto'; import { Pool } from 'pg'; const app = express(); const db = new Pool({ connectionString: process.env.DATABASE_URL }); // Webhook middleware app.use('/webhooks', express.raw({ type: 'application/json' })); class WebhookHandler { constructor() { this.secret = process.env.ZUBA_WEBHOOK_SECRET; } verifySignature(payload, signature, timestamp) { if (!this.secret || !signature || !timestamp) return false; // Reject timestamps older than 5 minutes const currentTime = Math.floor(Date.now() / 1000); if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300) { return false; } const expectedSignature = crypto .createHmac('sha256', this.secret) .update(`${timestamp}.${payload}`) .digest('hex'); try { return crypto.timingSafeEqual( Buffer.from(expectedSignature, 'hex'), Buffer.from(signature, 'hex'), ); } catch (error) { return false; } } async handleEvent(event) { switch (event.type) { case 'payout.paid': await this.updatePayoutStatus(event.data.id, 'completed'); break; default: console.log(`Unhandled event: ${event.type}`); } } async updatePayoutStatus(payoutId, status) { await db.query( 'UPDATE payouts SET status = $1, updated_at = NOW() WHERE id = $2', [status, payoutId], ); } } const handler = new WebhookHandler(); app.post('/webhooks/zuba', async (req, res) => { const signature = req.headers['x-zuba-signature']; const timestamp = req.headers['x-zuba-timestamp']; const payload = req.body.toString(); if (!handler.verifySignature(payload, signature, timestamp)) { return res.status(401).json({ error: 'Invalid signature' }); } const event = JSON.parse(payload); try { await handler.handleEvent(event); res.status(200).json({ received: true }); } catch (error) { console.error('Webhook processing failed:', error); res.status(500).json({ error: 'Processing failed' }); } }); app.listen(3000, () => { console.log('Webhook server running on port 3000'); }); ``` ## Next Steps * Learn about [error handling](/guides/error-handling) strategies for webhook failures * Explore the [API reference](/api-reference/introduction) for complete event schemas * Set up monitoring and alerting for production webhook endpoints * Implement webhook event replay for failed processing scenarios # Introduction Source: https://docs.zuba.com/introduction Zuba Payment Platform API: global payouts, payment acceptance, and multi-currency operations ## Overview The Zuba Payment Platform supports both fiat and stablecoin transactions, giving your business a single integration for global payouts, payment acceptance, and multi-currency operations, with built-in compliance and ledger management. ## Key Features > **[Global Payouts](/concepts/payouts)** > > Send payments worldwide via international transfers, SWIFT, US domestic rails (ACH and Fedwire), local bank transfers, mobile money, and crypto wallets > **[Payment Acceptance](/concepts/payins)** > > Accept payments through manual bank deposits > **[Multi-Currency Support](/concepts/overview#multi-currency-support)** > > Handle multiple currencies with real-time conversion and balance management > **[Ledger System](/concepts/ledger)** > > Double-entry accounting with full audit trails and compliance features ## What You Can Build With Zuba's API, you can create: * **Payroll Systems**: Automate salary payments across multiple countries and currencies * **Marketplace Platforms**: Handle seller payouts and buyer payments seamlessly * **Remittance Services**: Enable cross-border money transfers with competitive rates * **Digital Wallets**: Build consumer and business wallet applications * **B2B Payment Solutions**: Streamline business-to-business payment workflows ## Getting Started **1. Get API Credentials** Sign up for a Zuba account and obtain your API keys from the dashboard **2. Explore the API** Review our API reference and test endpoints in the interactive documentation **3. Create Your First Payout** Follow the [quickstart guide](/quickstart) to send your first payout **4. Integrate Webhooks** Set up [webhook endpoints](/guides/webhooks) to receive real-time payout status updates ## Support Need help getting started? Our team is here to assist you: * **Email**: [support@zuba.com](mailto:support@zuba.com) * **Documentation**: Browse our comprehensive [API reference](/api-reference/introduction) # MCP Server Source: https://docs.zuba.com/mcp-server Connect Claude Code, Codex, Cursor, VS Code, and other AI coding tools to the Zuba docs over the Model Context Protocol Zuba exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `https://docs.zuba.com/mcp` so AI coding tools (Claude Code, Codex, Cursor, VS Code, and others) can search and read these docs directly while you build your integration. > [!NOTE] > > The server is public and read-only: no token, password, or API key. Pointing a tool at the URL alone does not connect it; add the server to your tool's config using the snippets below. ## Add to your editor **Claude Code** Claude Code registers the server for you. Run: ```sh theme={"dark"} claude mcp add --transport http zuba-docs https://docs.zuba.com/mcp ``` Then confirm it connected: ```sh theme={"dark"} claude mcp list ``` **Codex** Add to `~/.codex/config.toml`: ```toml theme={"dark"} [mcp_servers.zuba-docs] url = "https://docs.zuba.com/mcp" ``` **Cursor** Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per project): ```json theme={"dark"} { "mcpServers": { "zuba-docs": { "url": "https://docs.zuba.com/mcp" } } } ``` **VS Code (Copilot)** Add to `.vscode/mcp.json`: ```json theme={"dark"} { "servers": { "zuba-docs": { "type": "http", "url": "https://docs.zuba.com/mcp" } } } ``` **Other** For a stdio-only client (Windsurf, Cline, Zed, and others), bridge to the remote server with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote): ```json theme={"dark"} { "mcpServers": { "zuba-docs": { "command": "npx", "args": ["-y", "mcp-remote", "https://docs.zuba.com/mcp"] } } } ``` ## Verify it works Once the server is connected, ask your assistant a question that only the docs can answer, for example: > Using the zuba-docs MCP server, how do I authenticate and send my first payout? It should pull the answer straight from these pages (including the money format, `clientRef` idempotency, and quote rules) instead of guessing. # Quickstart Source: https://docs.zuba.com/quickstart Start building with Zuba in under 5 minutes ## Set up your environment Get your API credentials, fund your Sandbox account, and send your first payout, all in a few minutes. ### Prerequisites Before you begin, ensure you have: * A Zuba account with API access * Your API credentials (Client ID and Client Secret) * Basic knowledge of REST APIs ### Get your API credentials 1. Log in to your [Zuba Sandbox Dashboard](https://sandbox.zuba.com) 2. Navigate to **API Settings** 3. Click **Generate API Key** or **Create API Credentials** 4. Save your Client ID, Client Secret, Token URL, and Audience securely See the [Authentication Guide](/authentication) for detailed instructions on obtaining access tokens. > [!WARNING] > > Never expose your Client Secret in client-side code or public repositories. Always keep it secure on your server. ### Rate Limits The API enforces rate limits to ensure fair usage and platform stability: | Limit | Requests | Window | | - | - | - | | Sustained | 100 | per minute | Limits are per client IP address, and some endpoints carry their own. If you exceed these limits, you'll receive a `429 Too Many Requests` response. Implement exponential backoff in your integration to handle rate limiting gracefully. ## Create your first beneficiary Before sending payouts, create a beneficiary with their banking details. This example creates a Nigerian beneficiary with an NGN bank account; the payout in the next step delivers to it over the local `bank_transfer` rail: ```bash curl theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/beneficiaries" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "John Doe", "email": "john.doe@example.com", "country": "NG", "address": "12 Marina Road", "city": "Lagos", "accounts": [{ "type": "bank_account", "currency": "NGN", "data": { "bankCode": "ng_0001", "crAccount": "1234567890" } }] }' ``` ```javascript JavaScript theme={"dark"} const beneficiary = await fetch('https://api.sandbox.zuba.com/v1/beneficiaries', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'John Doe', email: 'john.doe@example.com', country: 'NG', address: '12 Marina Road', city: 'Lagos', accounts: [{ type: 'bank_account', currency: 'NGN', data: { bankCode: 'ng_0001', crAccount: '1234567890' } }] }) }); const beneficiaryData = await beneficiary.json(); console.log(beneficiaryData); ``` ```python Python theme={"dark"} import requests beneficiary = requests.post( 'https://api.sandbox.zuba.com/v1/beneficiaries', headers={ 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, json={ 'name': 'John Doe', 'email': 'john.doe@example.com', 'country': 'NG', 'address': '12 Marina Road', 'city': 'Lagos', 'accounts': [{ 'type': 'bank_account', 'currency': 'NGN', 'data': { 'bankCode': 'ng_0001', 'crAccount': '1234567890' } }] } ) beneficiary_data = beneficiary.json() print(beneficiary_data) ``` ```java Java theme={"dark"} import java.net.http.*; import java.net.URI; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonArray; HttpClient client = HttpClient.newHttpClient(); JsonObject accountData = new JsonObject(); accountData.addProperty("bankCode", "ng_0001"); accountData.addProperty("crAccount", "1234567890"); JsonObject account = new JsonObject(); account.addProperty("type", "bank_account"); account.addProperty("currency", "NGN"); account.add("data", accountData); JsonArray accounts = new JsonArray(); accounts.add(account); JsonObject requestBody = new JsonObject(); requestBody.addProperty("name", "John Doe"); requestBody.addProperty("email", "john.doe@example.com"); requestBody.addProperty("country", "NG"); requestBody.addProperty("address", "12 Marina Road"); requestBody.addProperty("city", "Lagos"); requestBody.add("accounts", accounts); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/beneficiaries")) .header("Authorization", "Bearer YOUR_ACCESS_TOKEN") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(new Gson().toJson(requestBody))) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); JsonObject beneficiaryData = new Gson().fromJson(response.body(), JsonObject.class); System.out.println(beneficiaryData); ``` > [!NOTE] > > `bankCode` is the Zuba bank identifier from `GET /v1/banks` (e.g. `ng_0001` for Access Bank); the 3-digit CBN short code (e.g. `044`) and the 6-digit NIP long code stay accepted. `crAccount` is the 10-digit account number. The payout currency must match the currency of one of the beneficiary's accounts. For other destination currencies and account shapes (IBAN, US bank accounts, crypto wallets, mobile money), see [Payouts](/concepts/payouts#account-types). ## Check your account balance Before sending payouts, ensure your account has sufficient funds. You can check your balance across all currencies: ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/ledger/balances" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ```javascript JavaScript theme={"dark"} const balances = await fetch('https://api.sandbox.zuba.com/v1/ledger/balances', { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } }); const balanceData = await balances.json(); console.log(balanceData); ``` ```python Python theme={"dark"} import requests balances = requests.get( 'https://api.sandbox.zuba.com/v1/ledger/balances', headers={ 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } ) balance_data = balances.json() print(balance_data) ``` ```java Java theme={"dark"} HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/ledger/balances")) .header("Authorization", "Bearer YOUR_ACCESS_TOKEN") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); JsonObject balanceData = new Gson().fromJson(response.body(), JsonObject.class); System.out.println(balanceData); ``` > [!NOTE] > > Payouts fail if your balance is zero. In the Sandbox you can fund your workspace yourself; see [Sandbox testing](/guides/sandbox-testing#step-1-fund-your-workspace). In production, fund your account via the dashboard or contact your account manager. ## Send your first payout Now send a payout to your beneficiary, referencing them by ID from the previous step (recommended). `amount` is passed as a decimal string to avoid floating-point precision issues. **Currency fields:** `inputCurrency` is the balance you pay from, while `currency` is what the beneficiary receives (converted automatically if different). **Sender types:** `senderInfo.type` is `'individual'` (default when omitted) for natural-person senders, or `'business'` for legal entities. The examples below use an individual sender; for a business sender, see [Business sender example](#business-sender-example). ```bash curl theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/payouts" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clientRef": "PAYOUT-INV-001", "amount": "1000.00", "inputCurrency": "EUR", "currency": "NGN", "route": "bank_transfer", "senderInfo": { "firstName": "Jack", "lastName": "Jones", "address": "456 London Road", "city": "London", "postalCode": "SW1A 1AA", "country": "GB", "dateOfBirth": "1985-06-15" }, "beneficiary": { "id": "BENEFICIARY_ID_FROM_PREVIOUS_STEP" }, "reference": "Invoice #INV-001", "description": "Marketing services" }' ``` ```javascript JavaScript theme={"dark"} const payout = await fetch('https://api.sandbox.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-INV-001', amount: '1000.00', inputCurrency: 'EUR', currency: 'NGN', route: 'bank_transfer', senderInfo: { firstName: 'Jack', lastName: 'Jones', address: '456 London Road', city: 'London', postalCode: 'SW1A 1AA', country: 'GB', dateOfBirth: '1985-06-15' }, beneficiary: { id: beneficiaryData.id }, reference: 'Invoice #INV-001', description: 'Marketing services' }) }); const payoutData = await payout.json(); console.log(payoutData); ``` ```python Python theme={"dark"} import requests payout = requests.post( 'https://api.sandbox.zuba.com/v1/payouts', headers={ 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, json={ 'clientRef': 'PAYOUT-INV-001', 'amount': '1000.00', 'inputCurrency': 'EUR', 'currency': 'NGN', 'route': 'bank_transfer', 'senderInfo': { 'firstName': 'Jack', 'lastName': 'Jones', 'address': '456 London Road', 'city': 'London', 'postalCode': 'SW1A 1AA', 'country': 'GB', 'dateOfBirth': '1985-06-15' }, 'beneficiary': { 'id': beneficiary_data['id'] }, 'reference': 'Invoice #INV-001', 'description': 'Marketing services' } ) payout_data = payout.json() print(payout_data) ``` ```java Java theme={"dark"} JsonObject beneficiaryRef = new JsonObject(); beneficiaryRef.addProperty("id", beneficiaryData.get("id").getAsString()); JsonObject senderInfo = new JsonObject(); senderInfo.addProperty("firstName", "Jack"); senderInfo.addProperty("lastName", "Jones"); senderInfo.addProperty("address", "456 London Road"); senderInfo.addProperty("city", "London"); senderInfo.addProperty("postalCode", "SW1A 1AA"); senderInfo.addProperty("country", "GB"); senderInfo.addProperty("dateOfBirth", "1985-06-15"); JsonObject payoutRequest = new JsonObject(); payoutRequest.addProperty("clientRef", "PAYOUT-INV-001"); payoutRequest.addProperty("amount", "1000.00"); payoutRequest.addProperty("inputCurrency", "EUR"); payoutRequest.addProperty("currency", "NGN"); payoutRequest.addProperty("route", "bank_transfer"); payoutRequest.add("senderInfo", senderInfo); payoutRequest.add("beneficiary", beneficiaryRef); payoutRequest.addProperty("reference", "Invoice #INV-001"); payoutRequest.addProperty("description", "Marketing services"); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/payouts")) .header("Authorization", "Bearer YOUR_ACCESS_TOKEN") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(new Gson().toJson(payoutRequest))) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); JsonObject payoutData = new Gson().fromJson(response.body(), JsonObject.class); System.out.println(payoutData); ``` > [!NOTE] > > `clientRef` is your idempotency reference: it must be unique per workspace, and you can look a payout up by it later. For safe retries, you can also send an optional `Idempotency-Key` header on `POST /v1/payouts`: a repeat with the same key and body replays the original response instead of creating a duplicate payout. ### Business sender example When the sender is a legal entity rather than a natural person, set `senderInfo.type` to `'business'` and provide `companyName`, `registrationNumber`, and `country`. The `registrationNumber` is the company's official registration identifier and is used as the AML pivot for sanctions and UBO screening. Business senders do not carry `firstName`, `lastName`, or `dateOfBirth`. ```bash curl theme={"dark"} curl -X POST "https://api.sandbox.zuba.com/v1/payouts" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clientRef": "PAYOUT-INV-002", "amount": "1000.00", "inputCurrency": "EUR", "currency": "NGN", "route": "bank_transfer", "senderInfo": { "type": "business", "companyName": "Acme Trading Ltd", "registrationNumber": "12345678", "address": "1 Finsbury Square", "city": "London", "postalCode": "EC2A 1AE", "country": "GB" }, "beneficiary": { "id": "BENEFICIARY_ID_FROM_PREVIOUS_STEP" }, "reference": "Invoice #INV-002", "description": "Marketing services" }' ``` ```javascript JavaScript theme={"dark"} const payout = await fetch('https://api.sandbox.zuba.com/v1/payouts', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientRef: 'PAYOUT-INV-002', amount: '1000.00', inputCurrency: 'EUR', currency: 'NGN', route: 'bank_transfer', senderInfo: { type: 'business', companyName: 'Acme Trading Ltd', registrationNumber: '12345678', address: '1 Finsbury Square', city: 'London', postalCode: 'EC2A 1AE', country: 'GB' }, beneficiary: { id: beneficiaryData.id }, reference: 'Invoice #INV-002', description: 'Marketing services' }) }); const payoutData = await payout.json(); console.log(payoutData); ``` ```python Python theme={"dark"} import requests payout = requests.post( 'https://api.sandbox.zuba.com/v1/payouts', headers={ 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, json={ 'clientRef': 'PAYOUT-INV-002', 'amount': '1000.00', 'inputCurrency': 'EUR', 'currency': 'NGN', 'route': 'bank_transfer', 'senderInfo': { 'type': 'business', 'companyName': 'Acme Trading Ltd', 'registrationNumber': '12345678', 'address': '1 Finsbury Square', 'city': 'London', 'postalCode': 'EC2A 1AE', 'country': 'GB' }, 'beneficiary': { 'id': beneficiary_data['id'] }, 'reference': 'Invoice #INV-002', 'description': 'Marketing services' } ) payout_data = payout.json() print(payout_data) ``` ```java Java theme={"dark"} JsonObject businessSenderInfo = new JsonObject(); businessSenderInfo.addProperty("type", "business"); businessSenderInfo.addProperty("companyName", "Acme Trading Ltd"); businessSenderInfo.addProperty("registrationNumber", "12345678"); businessSenderInfo.addProperty("address", "1 Finsbury Square"); businessSenderInfo.addProperty("city", "London"); businessSenderInfo.addProperty("postalCode", "EC2A 1AE"); businessSenderInfo.addProperty("country", "GB"); JsonObject businessPayoutRequest = new JsonObject(); businessPayoutRequest.addProperty("clientRef", "PAYOUT-INV-002"); businessPayoutRequest.addProperty("amount", "1000.00"); businessPayoutRequest.addProperty("inputCurrency", "EUR"); businessPayoutRequest.addProperty("currency", "NGN"); businessPayoutRequest.addProperty("route", "bank_transfer"); businessPayoutRequest.add("senderInfo", businessSenderInfo); businessPayoutRequest.add("beneficiary", beneficiaryRef); businessPayoutRequest.addProperty("reference", "Invoice #INV-002"); businessPayoutRequest.addProperty("description", "Marketing services"); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/payouts")) .header("Authorization", "Bearer YOUR_ACCESS_TOKEN") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(new Gson().toJson(businessPayoutRequest))) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); JsonObject payoutData = new Gson().fromJson(response.body(), JsonObject.class); System.out.println(payoutData); ``` ## Track payout status Check the status of your payout at any time: ```bash curl theme={"dark"} curl -X GET "https://api.sandbox.zuba.com/v1/payouts/YOUR_PAYOUT_ID" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` ```javascript JavaScript theme={"dark"} const status = await fetch(`https://api.sandbox.zuba.com/v1/payouts/${payoutData.id}`, { headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } }); const statusData = await status.json(); console.log('Payout status:', statusData.status); ``` ```python Python theme={"dark"} import requests status = requests.get( f'https://api.sandbox.zuba.com/v1/payouts/{payout_data["id"]}', headers={ 'Authorization': 'Bearer YOUR_ACCESS_TOKEN' } ) status_data = status.json() print(f'Payout status: {status_data["status"]}') ``` ```java Java theme={"dark"} String payoutId = payoutData.get("id").getAsString(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.zuba.com/v1/payouts/" + payoutId)) .header("Authorization", "Bearer YOUR_ACCESS_TOKEN") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); JsonObject statusData = new Gson().fromJson(response.body(), JsonObject.class); System.out.println("Payout status: " + statusData.get("status").getAsString()); ``` `status` is one of `created`, `queued`, `processing`, `paid`, `failed`, or `cancelled`; see [Payout statuses](/concepts/payouts#payout-statuses) for what each means. For push notifications instead of polling, set up [webhooks](/guides/webhooks). ## Next Steps > **[Authentication](/authentication)** > > Learn about secure authentication methods > **[Webhooks](/guides/webhooks)** > > Set up real-time payout notifications > **[Batch Payouts](/guides/batch-payouts)** > > Process multiple payouts efficiently > **[API Reference](/api-reference/introduction)** > > Explore the complete API documentation # What Zuba Can Do Today Source: https://docs.zuba.com/what-zuba-can-do-today Live currency coverage and rails, and what is coming next Zuba moves money between Africa, Europe, the Americas, Asia, and stablecoin rails: collections in, FX conversion, payouts out, all behind one API and dashboard. | Currency | Name | Rails & networks | | :- | :- | :- | | ![AED](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/ae.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=3adf49f92c72dac1cfedcc8be0d47905) **AED** | UAE Dirham | UAEFTS, SWIFT | | ![AMD](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/am.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=10981925f24c86067f829f9cff92bd95) **AMD** | Armenian Dram | SWIFT | | ![ARS](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/ar.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=218ef9c488766e08f7ba0cf1523af444) **ARS** | Argentine Peso | COELSA | | ![AUD](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/au.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=7290ac9d13e686b807e7acdec42848f7) **AUD** | Australian Dollar | BECS, SWIFT | | ![BHD](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/bh.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=cd046a491b3da11893347540b10efbe3) **BHD** | Bahraini Dinar | SWIFT | | ![BOB](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/bo.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=770cb737be4bb19289fbabb514b5734a) **BOB** | Bolivian Boliviano | Bank transfer | | ![BRL](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/br.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=72758382a659b3cf63fa3e47cdf69dea) **BRL** | Brazilian Real | PIX | | ![BWP](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/bw.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=12aabb175342f2af9833f4dabc361127) **BWP** | Botswana Pula | Bank transfer, mobile money | | ![CAD](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/ca.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=2c16757139eb701476096a8d13e23c4e) **CAD** | Canadian Dollar | EFT, SWIFT | | ![CDF](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/cd.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=f3a41190e32d1e5fd5a49929fd579672) **CDF** | Congolese Franc | Mobile money | | ![CHF](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/ch.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=8fa804d3523b8930e514c559010f8ebd) **CHF** | Swiss Franc | Bank transfer (Swiss IBAN) | | ![CLP](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/cl.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=fa11dcbebcc6615ff0fd026609d69410) **CLP** | Chilean Peso | Bank transfer | | ![CNY](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/cn.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=2e3f9a25d272968cf61f838b5856c6b5) **CNY** | Chinese Yuan | SWIFT, Alipay, WeChat Pay | | ![COP](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/co.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=e269c54e8bef58e82e0742e561174381) **COP** | Colombian Peso | Bre-B, ACH, PSE | | ![CZK](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/cz.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=76fc559cc6078ea1b71e19922f0aae3d) **CZK** | Czech Koruna | Bank transfer | | ![DKK](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/dk.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=07f54bb15566f57e188a2c794bd5f414) **DKK** | Danish Krone | SWIFT | | ![EGP](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/eg.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=cc728cc3b64c713f813fb4149894214a) **EGP** | Egyptian Pound | Bank transfer, mobile money | | ![ETB](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/et.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=c8f778422da15a705812eaf23f918cf7) **ETB** | Ethiopian Birr | Bank transfer, mobile money | | ![EUR](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/eu.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=e8fb9139dbcdd325cd0a077b02057966) **EUR** | Euro | SEPA (IBAN) | | ![GBP](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/gb.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=56a378e15145c258c20aae5ada0eda04) **GBP** | British Pound Sterling | Faster Payments (IBAN) | | ![GEL](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/ge.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=55fc8ceae36d20dc0e7ccb62869a0e8f) **GEL** | Georgian Lari | SWIFT | | ![GHS](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/gh.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=0ce7bb9b22203707c5470a72c2b0cac4) **GHS** | Ghanaian Cedi | Bank transfer, mobile money | | ![GMD](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/gm.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=c2eb8436d06b606961721a0a2f1c6049) **GMD** | Gambian Dalasi | Mobile money | | ![GNF](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/gn.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=25dba2beb9d1ad7d165856e6492e9cc1) **GNF** | Guinean Franc | Mobile money | | ![HKD](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/hk.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=14e23ed95ef57e14402f65efa01eb559) **HKD** | Hong Kong Dollar | FPS, SWIFT | | ![IDR](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/id.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=35384d63b44e9f0e78811d0b287ae467) **IDR** | Indonesian Rupiah | BI-FAST, SWIFT | | ![INR](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/in.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=8967c0f73adce1e4a35e24b969193dea) **INR** | Indian Rupee | IMPS / NEFT, SWIFT | | ![JPY](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/jp.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=35218caec06ee01038e4c8862b959bed) **JPY** | Japanese Yen | SWIFT | | ![KES](https://mintcdn.com/zuba-e80f95d1/u4hKxtGUnyeyirtd/assets/flags/ke.svg?fit=max\&auto=format\&n=u4hKxtGUnyeyirtd\&q=85\&s=8714bbf2cf8b6341f6abcf8d456078c4) **KES** | Kenyan Shilling | Bank transfer, mobile money | | ![KRW](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/kr.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=7d7f5426254331e1f0094bb1896548a7) **KRW** | South Korean Won | KFTC, SWIFT | | ![LRD](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/lr.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=507f898b06443a28b24bd93233c7329e) **LRD** | Liberian Dollar | Mobile money | | ![LSL](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/ls.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=5c78e3db26b8fbe665c42b28f138937b) **LSL** | Lesotho Loti | Mobile money | | ![MWK](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/mw.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=58f6054c6a7bff63825d1b671093fc4f) **MWK** | Malawian Kwacha | Bank transfer, mobile money | | ![MXN](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/mx.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=dec62872dc1019a53f4f680afcd6296a) **MXN** | Mexican Peso | SPEI | | ![MYR](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/my.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=9a30a812477b0b79aa5bc0578d351446) **MYR** | Malaysian Ringgit | SWIFT | | ![MZN](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/mz.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=e4141cf595f4be145090b3c3e50780e9) **MZN** | Mozambican Metical | Bank transfer, mobile money | | ![NGN](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/ng.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=0a0f889fcd53f23474c1039f08d1f1e9) **NGN** | Nigerian Naira | Bank transfer (NIP) | | ![NOK](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/no.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=696090f10a4e79eb8d3608e1d795e7f0) **NOK** | Norwegian Krone | Bank transfer | | ![NZD](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/nz.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=a03b0e59517d28dc0344caa37d44c95d) **NZD** | New Zealand Dollar | SWIFT | | ![OMR](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/om.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=918a4053764d9f8dc621c27378162943) **OMR** | Omani Rial | SWIFT | | ![PEN](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/pe.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=dbac587f1ccaae741c464f935403f55a) **PEN** | Peruvian Sol | Bank transfer | | ![PHP](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/ph.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=051ed4bd37d69b2ba36e0e1ef9c4f8fc) **PHP** | Philippine Peso | InstaPay / PESONet, SWIFT | | ![PLN](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/pl.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=f2d0ec9f3e9c8ceef4117ae745933216) **PLN** | Polish Zloty | SWIFT | | ![RSD](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/rs.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=f3541e716040f323e1a8b77ccedcc11c) **RSD** | Serbian Dinar | SWIFT | | ![RWF](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/rw.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=4c69dce5511f57510ac5641668ff4e98) **RWF** | Rwandan Franc | Bank transfer, mobile money | | ![SEK](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/se.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=5633bab0aabdd2d040a6ec12f56c83c6) **SEK** | Swedish Krona | SWIFT | | ![SGD](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/sg.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=c8f8fb4f03c175d4d5c9ed4adf203a96) **SGD** | Singapore Dollar | FAST, SWIFT | | ![SLE](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/sl.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=6d0989bf9941e9fe1acce3850cabe0bc) **SLE** | Sierra Leonean Leone | Mobile money | | ![THB](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/th.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=7757f8ae6a368c3ef952dd03bde32f87) **THB** | Thai Baht | PromptPay, SWIFT | | ![TRY](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/tr.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=bea233d9fd394c29948ec38e948689c3) **TRY** | Turkish Lira | SWIFT | | ![TWD](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/tw.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=dafa0a48f3d4b0f0c8ee4ee924b352a6) **TWD** | New Taiwan Dollar | SWIFT | | ![TZS](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/tz.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=b78b0cb4a1fc180f36d9e8b9e9e2ca2c) **TZS** | Tanzanian Shilling | Bank transfer, mobile money | | ![UGX](https://mintcdn.com/zuba-e80f95d1/u4hKxtGUnyeyirtd/assets/flags/ug.svg?fit=max\&auto=format\&n=u4hKxtGUnyeyirtd\&q=85\&s=f4a5b7ad4d673ce87ef1291f33e202a6) **UGX** | Ugandan Shilling | Bank transfer, mobile money (early access) | | ![USD](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/us.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=75abc8badee60e457e83220d8d76386c) **USD** | US Dollar | ACH, Fedwire, SWIFT, CHATS, CFXPS, SPID | | ![UZS](https://mintcdn.com/zuba-e80f95d1/sFgz0O0FEpgywRyQ/assets/flags/uz.svg?fit=max\&auto=format\&n=sFgz0O0FEpgywRyQ\&q=85\&s=10344fffb5eb89999f71f84c5452a388) **UZS** | Uzbekistani Som | SWIFT | | ![VND](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/vn.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=52b4df3bde942f8db952d84f2329735e) **VND** | Vietnamese Dong | SWIFT | | ![XAF](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/cm.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=bc21bb4feae04348e6fe21805036d867) **XAF** | Central African CFA Franc | Mobile money, Cameroon; bank transfer, Cameroon | | ![XOF](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/ci.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=611e5f9d57409afc93123abc9e7b9195) **XOF** | West African CFA Franc | Mobile money, 6 markets; bank transfer, Côte d'Ivoire | | ![ZAR](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/za.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=6935c10b40d6504825d65870fed21ef2) **ZAR** | South African Rand | Bank transfer, SWIFT | | ![ZMW](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/zm.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=157bba6e093ee2e34c8e3c1bd501323e) **ZMW** | Zambian Kwacha | Bank transfer, mobile money | | ![USDC](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/usdc.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=372cbe262e348e3dfe85e6dbb16a697a) **USDC** | USD Coin | Ethereum, Solana | | ![USDT](https://mintcdn.com/zuba-e80f95d1/8QECGswvOcVt0Qqs/assets/flags/usdt.svg?fit=max\&auto=format\&n=8QECGswvOcVt0Qqs\&q=85\&s=298028301e44ef3aac24b38860c62419) **USDT** | USD Tether | Ethereum, Solana, Tron | SWIFT reaches most major markets and keeps expanding. Check a corridor with [`GET /v1/payouts/available-rails`](/concepts/payouts). [Collections](/concepts/payins) are live in NGN and AED (virtual accounts), USD (virtual accounts and managed deposit accounts), EUR and GBP (named accounts), KES (virtual accounts, funded by bank transfer or M-Pesa), ZAR (virtual accounts, funded by bank transfer), XOF and XAF (order-funding collection accounts), and USDC / USDT (on-chain). On top of the rails: firm single-use [FX quotes](/concepts/payouts), multi-currency balances and [conversions](/concepts/ledger), [trade-desk orders](/guides/orders) for large transfers, [webhooks](/guides/webhooks), and a double-entry [ledger](/concepts/ledger), all in both the dashboard and the [API](/api-reference/introduction). Looking for a currency you don't see here? Our roadmap is always expanding. Tell us at [support@zuba.com](mailto:support@zuba.com).