> ## 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, and deposit 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. # 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} # 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": "Validation failed", "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", "value": { "crAccount": "1234567890", "bankCode": "123123333" } } ] } ``` 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 across three windows: | Window | Limit | | -------- | -------------- | | 1 second | 10 requests | | 1 minute | 100 requests | | 1 hour | 1,000 requests | Exceeding any window returns HTTP `429 Too Many Requests`. 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 ``` 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 Send your first payout in minutes Send money globally to beneficiaries Double-entry accounting and transaction tracking 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. # Resolve a crypto deposit address Source: https://docs.zuba.com/api-reference/pay-ins/resolve-a-crypto-deposit-address /openapi.json post /v1/deposits/crypto-address Provision (or return the existing) on-chain deposit address for the requested network. A wallet is created only for the requested network. Send USDT/USDC to the returned address; the deposit is credited once it confirms on-chain, provided it meets the per-transfer minimum. A smaller transfer is not credited, does not appear in `GET /v1/deposits`, and cannot be recovered. EVM addresses are valid across all EVM chains. Pass `Zuba-Account-Id` to allocate the address to a sub-account: deposits to it settle into that sub-account's balance, and each sub-account gets its own address. # Cancel a payout Source: https://docs.zuba.com/api-reference/payouts/cancel-a-payout /openapi.json post /v1/payouts/{id}/cancel Cancel a pending payout transaction # 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 any submitted account (IBAN or NGN crAccount+bankCode) matches an existing active beneficiary for this client, 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 any account matches an existing active beneficiary for this client, the existing 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 beneficiary field requirements for a currency Source: https://docs.zuba.com/api-reference/payouts/get-beneficiary-field-requirements-for-a-currency /openapi.json get /v1/payouts/requirements Returns required and optional beneficiary fields based on destination currency. Aggregates requirements across all providers that support the currency. # 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 # 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 (plan 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 only and locks the rate until it is consumed by the matching executor or expires; a payout's fixed fee and payment rail are 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`; poll `GET /v1/virtual-accounts` until `active`. 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`. # 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. # 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 * **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) **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; } ``` ## 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) | 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 | Token lacks required permissions | Contact support to verify account permissions | Example error response: ```json theme={"dark"} { "statusCode": 401, "message": "Unauthorized" } ``` ## Security Best Practices **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; ``` **Always use HTTPS** for both token requests and API calls. Never send credentials or tokens over HTTP. 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. * 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 Request only the permissions your application needs. Contact support to configure specific scopes for your credentials. ## 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 Start making your first API calls 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. 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` | Transaction amount | | `currency` | ISO 4217 currency code | | `balanceAfter` | Your account balance after this transaction | | `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 Zuba service fee; lets you label fee rows 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. Fee entries appear as separate debits with `isFee: true`; they are internal transfers, so no `counterparty` is present. 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 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", "balanceAfter": "1100.00000000", "depositId": "d1e2f3a4-5b6c-7d8e-9f0a-1b2c3d4e5f6a", "status": "confirmed", "createdAt": "2026-01-12T12:00:00.000Z", "confirmedAt": "2026-01-12T12:00:00.000Z" } ``` ### 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' ? '+' : '-'; console.log(`${direction}${tx.amount} ${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 '-' print(f"{direction}{tx['amount']} {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") ? "+" : "-"; System.out.println(direction + transaction.get("amount").getAsString() + " " + 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 * 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 * Transaction history export for audits * Account statements for any period * End-of-day closing balances for daily reconciliation * 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", "balanceAfter": "1500.00000000", "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. ### 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. ## 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. ### Common Error Scenarios | Error | Description | Resolution | | ------------------- | ----------------------------------------------- | ---------------------------------------------------------- | | Insufficient funds | Not enough balance to complete the payout | Add funds to your account | | 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 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 * 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 * 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 * 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 Send your first payout with the Zuba API Set up real-time notifications for payment events Process multiple payouts efficiently 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: 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. Accept payments via bank-transfer deposits with unique account details per deposit, plus crypto deposit addresses. Funds are credited to your ledger on receipt. Double-entry accounting with multi-currency support, real-time balance tracking, and comprehensive audit trails. 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, XOF, and XAF, plus the stablecoins USDC, USDT, and EURC, with real-time conversion between supported corridors: * **Fiat Currencies**: EUR, USD, GBP, NGN, GHS, XOF, XAF * **Stablecoins**: USDC, USDT, EURC 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` ## Compliance Framework Built-in compliance features ensure regulatory adherence: * Identity verification for beneficiaries * Document collection and validation * Risk assessment and scoring * Ongoing monitoring and updates * Transaction monitoring and screening * Sanctions and watchlist screening * Suspicious activity reporting * Transaction limits and controls * 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) and Ghana (GHS) * **Mobile money**: West Africa (XOF), Cameroon (XAF), and Ghana (GHS) * **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)) 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 fixed 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 managed USD deposit account are credited automatically. * **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 ### Fixed virtual account Your workspace has a dedicated NGN virtual account in your business name (`Zuba {Merchant Name}`). Transfers sent to it are credited to your NGN balance automatically. No create call is required. 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. Contact your account manager if no virtual account has been provisioned yet. ### 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" } } ``` 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. ## 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 `virtual_account` in `GET /v1/deposits`. The account details are shown in the dashboard. Contact your account manager to provision named accounts. ## USD Bank Deposits USD deposits settle into a managed deposit account provisioned for your business. Transfers sent to your account details are credited to your USD balance automatically and appear as deposits with method `bank_deposit` in `GET /v1/deposits`. No create call is required. Contact your account manager to provision a USD deposit account. ## Crypto Deposits Provision an on-chain deposit address with `POST /v1/deposits/crypto-address`, then send USDC or USDT to it. The deposit is recorded and credited once the transfer confirms on-chain, and appears with method `crypto_deposit`. **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/deposits/crypto-address" \ -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/deposits/crypto-address', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_ACCESS_TOKEN', 'Content-Type': 'application/json' }, body: JSON.stringify({ network: 'eip155:1', clientRef: 'customer-1234' }) }); const { network, address, clientRef } = await response.json(); // { network: 'eip155:1', address: '0x1234...5678', clientRef: 'customer-1234' } ``` `network` is a CAIP-2 chain identifier: | Network | `network` value | Assets credited | | ----------------- | ----------------------------------------- | --------------- | | Ethereum (ERC-20) | `eip155:1` | USDC, USDT | | Solana (SPL) | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | USDC, USDT | | Tron (TRC-20) | `tron:mainnet` | USDT | Address behaviour: * **Addresses are reused.** Omitting `clientRef` returns your single default address for the network; the same call always returns the same address. Pass distinct `clientRef` values (for example, your end-customer IDs) to provision distinct addresses. * **The address is asset-agnostic**: it receives any supported stablecoin on its network. * **EVM addresses are valid across all EVM chains.** * A `409 Conflict` means the address for that client/network/reference is frozen or archived and cannot be re-created. 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 ``` ## 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` | Return of funds to the sender in progress | | `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. ``` Deposit status changes are not currently delivered as webhook events. The webhook event catalog covers payout, order, and account events (see the [Webhooks guide](/guides/webhooks)). Poll `GET /v1/deposits/{id}` to track a deposit: fetching a `pending` or `processing` deposit also triggers an asynchronous status refresh, so polling keeps the record fresh. ## 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. ## 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` | `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 * 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 * Poll `GET /v1/deposits/{id}` with exponential backoff * Handle all eight statuses, including `in_review`, `refunding`, and `refunded` * Treat `in_review` as not-yet-credited: funds are held for compliance review * Log deposit events for audit and debugging * 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 Send money globally to beneficiaries Real-time notifications for payout, order, and account events Track transactions with double-entry accounting 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: For the local bank rails (Nigeria, Ghana, Kenya, Uganda, Cameroon, Côte d'Ivoire), `bankCode` is Zuba's internal bank identifier. Fetch the supported banks and their codes for a country from `GET /v1/accounts/banks?country=` (e.g. `?country=CM`), which returns `[{ "name", "bankCode" }]` sorted by name. The list is scoped to banks Zuba can currently pay, so for Kenya it is the payable subset rather than every registered bank. Nigeria (the default) also accepts the raw CBN/NIP code directly. **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` accepts the 3-digit CBN short code (e.g. `044`) or the 6-digit NIP long code (e.g. `000014`); `crAccount` is the 10-digit account number. ```json theme={"dark"} { "type": "bank_account", "currency": "NGN", "data": { "bankCode": "044", "crAccount": "1234567890" } } ``` **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" } } ``` KES **bank** payouts are in early access — a gated corridor. An unpinned workspace's KES bank payout is rejected at creation with `RAIL_UNAVAILABLE`. KES **mobile money** is gated on the same corridor. Enablement can be granted per rail, so ask your account manager for the rails you need. **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 bank payouts additionally require the sender's KYC.** An **individual** sender must supply, in `senderInfo`: `firstName` and `lastName`, `phoneNumber` in international format, `fundOrigin` (`SALARY`, `BUSINESS`, or `INVESTMENT`), and `gender` (`M` or `F`), or the payout is rejected at creation. To send as your own workspace, omit `senderInfo` entirely — your account is then used as the business originator. Supply `senderInfo` 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' }, reference: 'Supplier payment' }) }); ``` XAF **bank** payouts are in early access — a gated corridor. An unpinned workspace's XAF bank payout is rejected at creation with `RAIL_UNAVAILABLE`. Contact your account manager to enable the corridor for your workspace. (XAF **mobile money** is enabled separately — see the **Mobile Money** tab.) **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, your workspace's legal name is used as the originator's first and last name instead. Unlike Cameroon, Côte d'Ivoire does not need `phoneNumber`, `fundOrigin`, or `gender`. XOF **bank** payouts are in early access — a gated corridor. An unpinned workspace's XOF bank payout is rejected at creation with `RAIL_UNAVAILABLE`. Contact your account manager to enable the corridor for your workspace. (XOF **mobile money** is enabled separately — see the **Mobile Money** tab.) **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. 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. **Sandbox-only currencies** **ZMW** (Zambian kwacha), **MZN** (Mozambican metical), **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. ```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 `MZN`, `MWK`, and `EGP`: any `bankCode` is accepted in the sandbox. 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. **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 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). **KES** and **UGX** mobile money are in early access, gated on the same corridor as their 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 and GHS 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: 'Payment for marketing services' }) }); ``` `senderInfo` is optional. When omitted, your workspace is used as the business originator. Some corridors require it: GHS mobile money needs the sender's first and last name, and XAF mobile money needs the sender's name and phone number. ### 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. The named sub-account must be one you own and must be **active**. A header naming an account you do not own is rejected with `403 not_account_owner`, and a sub-account that is not yet active is rejected with `422 ACCOUNT_NOT_ACTIVE`. 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) | 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 | **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. ### 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–25,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 bank payouts carry extra sender KYC (`fundOrigin` and `gender`) — 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`.** Both corridors are 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 sender details.** Supply the originator's name and phone in `senderInfo`, or the payout is rejected: ```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' }, reference: 'Supplier payment' }) }); ``` **XOF**, **XAF** and **GHS** mobile money are open. **KES** and **UGX** are 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 from a non-USD balance (e.g. NGN) 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 | Review error, retry | | `cancelled` | Cancelled by user or system | Funds returned | 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 an amount cap, and a payout or payout quote above 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 * Always validate beneficiary details before creating * Use `GET /v1/payouts/requirements` to check the beneficiary fields required for a currency before submitting * Store beneficiary IDs for repeat payments * Keep beneficiary information up to date * 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 * Never expose API keys in client-side code * Validate webhook signatures * Use HTTPS for all API communications * Implement proper access controls ## Next Steps Step-by-step guide to send your first payout Learn how to process multiple payouts efficiently Set up real-time notifications for payout status 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. 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 payment" }, { 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 payment" }, { 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 payment" } ]; 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`); ``` `senderInfo` is only needed when paying out on behalf of a third party. When omitted, your workspace is used as the business originator. 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, // 3-digit CBN code (e.g. "044") or 6-digit NIP code 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. Use `GET /v1/payouts/requirements` to check the required fields for a currency, and see [Account Types](/concepts/payouts#account-types) for every shape. ## 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; the other payouts succeed normally. 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` `Payout with this client reference already exists`. * In a batch, a duplicate `clientRef` surfaces as an inline `{ error, clientRef }` element in the `201` response array; 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 the previously failed rows create payouts. 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', 'EURC', 'NGN', 'XOF', 'XAF', 'GHS']; 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, or a field validation failure. 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 (insufficient funds, 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 (`INSUFFICIENT_FUNDS`) * **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/insufficient-funds", "title": "Insufficient funds", "status": 409, "detail": "Insufficient funds to process this payout", "code": "INSUFFICIENT_FUNDS", "category": "BUSINESS_DECLINE", "request_id": "3f9d2b6e-1c47-4a58-9e0b-7a12c4d8f631", "retryable": false, "instance": "/v1/payouts" } ``` | 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" } ``` ### Validation shape Field validation failures are `400` responses with a `details` array. See [Recipient field validation](#recipient-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"} 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; // problem+json envelope; undefined on legacy bodies 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); } ``` Legacy bodies carry no `code`; treat a missing 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 | | `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 | 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_FUNDS': // Top up the balance or reduce the amount; do not resubmit unchanged. return { action: 'TOPUP_BALANCE', retry: false }; case 'RECIPIENT_DETAILS_INVALID': // Fix the beneficiary's details, then submit a new payout. return { action: 'UPDATE_BENEFICIARY', retry: false }; case 'LIMIT_EXCEEDED': // Contact your account manager about limits. Amounts above corridor // caps are served by the orders channel (see /guides/orders). return { action: 'CONTACT_ACCOUNT_MANAGER', retry: false }; case 'IDEMPOTENCY_KEY_PROCESSING': // The first request with this key is still in flight: // retry the identical request to receive its response. return { action: 'RETRY_SAME_REQUEST', retry: true }; default: return { action: error.retryable ? 'RETRY_LATER' : 'CONTACT_SUPPORT', retry: error.retryable }; } } ``` ## Validation Errors ### Recipient field validation Some recipient requirements are validated **synchronously** when you submit a payout (`POST /v1/payouts`) or create or update a beneficiary (`POST /v1/beneficiaries` / `PUT /v1/beneficiaries/{id}`). 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 fails one of these checks the API responds with `400` and a `details` array. Each entry names the failing `field`, a stable machine `code`, and a human-readable `message`: ```json theme={"dark"} { "statusCode": 400, "error": "BAD_REQUEST", "message": "Please provide a first and last name for this recipient. The destination bank requires both.", "details": [ { "field": "name", "code": "MISSING_BENEFICIARY_NAME", "message": "Please provide a first and last name for this recipient. The destination bank requires both." } ] } ``` 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 required field (address, city, postcode, 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. | | `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. | These checks run before the payout is queued, so an invalid recipient is rejected immediately at submit time rather than failing later and being refunded. Fix the named field and resubmit. ### 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', 'EURC', 'NGN', 'XOF', 'XAF', 'GHS']; const ROUTES = ['bank_transfer', 'ach', 'fedwire', 'swift', 'crypto', 'mobile_money']; function validatePayout(payout) { const errors = []; // clientRef: required, unique per payout, max 100 characters if (!payout.clientRef || payout.clientRef.length > 100) { errors.push('clientRef is required (max 100 characters, unique per payout)'); } // amount: positive decimal string with up to 8 decimal places if (!/^\d+(\.\d{1,8})?$/.test(payout.amount ?? '') || Number(payout.amount) <= 0) { errors.push('amount must be a positive decimal string with up to 8 decimal places'); } if (!PRODUCTION_CURRENCIES.includes(payout.currency)) { errors.push(`currency must be one of: ${PRODUCTION_CURRENCIES.join(', ')}`); } // Reference an existing beneficiary by id if (!payout.beneficiary?.id) { errors.push('beneficiary.id is required'); } // route is optional: when set, it must be a live route if (payout.route && !ROUTES.includes(payout.route)) { errors.push(`route must be one of: ${ROUTES.join(', ')}`); } return errors; } ``` A few semantics worth encoding in your client: * **`clientRef` is a duplicate guard, not a replay mechanism.** Submitting a second payout with the same `clientRef` returns `400` with the message `Payout with this client reference already exists`. It does not return the original payout. After an ambiguous failure (timeout, dropped connection), look the payout up with `GET /v1/payouts?clientRef=` 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–25,000) and per client, surfaced as `LIMIT_EXCEEDED`. See [Payouts](/concepts/payouts) for corridor-specific caps. * **Currencies.** EUR, USD, GBP, USDC, EURC, and NGN are enabled by default; USDT, XOF, XAF, and GHS are enabled via your account manager. ZMW, MZN, MWK, and EGP are sandbox-only. See the currency-to-route matrix in [Payouts](/concepts/payouts#payment-routes). * **EUR and GBP payouts need no route.** They deliver to `iban` accounts over Zuba's international transfer network; the rail is selected automatically and surfaces as `international` on payout reads. See [Payment Routes](/concepts/payouts#payment-routes). ## Retry Strategies ### Idempotent retries with Idempotency-Key `POST /v1/payouts` accepts an optional `Idempotency-Key` header: a client-generated key, scoped to your workspace. This is the replay-safe retry mechanism: * **Same key + same body**: replays the original response without creating a second payout. * **Same key + different body**: rejected with `422 IDEMPOTENCY_KEY_REUSED`. This indicates a bug in your client; never retry it with the same key. * **Same key while the first request is still in flight**: returns a **retryable** `409 IDEMPOTENCY_KEY_PROCESSING`. Retry the identical request to receive the stored response. * Keys expire 24 hours after the original request completes; a repeat after expiry is treated as a new request. Always send an `Idempotency-Key` when creating payouts. Without one, the envelope's `retryable` flag is forced to `false` on the response, and a blind retry of a create that actually committed hits the `clientRef` duplicate guard (`400`) instead of getting the payout back. ### Exponential Backoff Retry on network errors and on responses whose envelope says `retryable: true`, reusing the same key on every attempt: ```typescript theme={"dark"} async function createPayoutWithRetry(payout, { maxAttempts = 4, baseDelayMs = 1000 } = {}) { const idempotencyKey = crypto.randomUUID(); for (let attempt = 1; ; attempt++) { try { const response = await fetch('https://api.zuba.com/v1/payouts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_API_KEY', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify(payout) }); return await handleZubaResponse(response); } catch (error) { // Branch on the envelope's retryable flag, not on status-code heuristics. // Network errors are safe to retry because the key deduplicates them. const retryable = error instanceof ZubaAPIError ? error.retryable : true; if (!retryable || attempt >= maxAttempts) { throw error; } const delay = baseDelayMs * 2 ** (attempt - 1) * (1 + Math.random() * 0.1); await new Promise((resolve) => setTimeout(resolve, delay)); } } } ``` Prefer the envelope's `retryable` flag over hand-rolled status heuristics: it already encodes the safety rules, including the retryable `409 IDEMPOTENCY_KEY_PROCESSING` (a 4xx a naive "never retry 4xx" rule would wrongly treat as permanent). On legacy endpoints without the envelope, retry only `429`, `502`, `503`, `504`, and network errors. Never blind-retry a `POST` that lacks idempotency protection. ## Status Monitoring ### Webhooks first [Webhooks](/guides/webhooks) are the primary status mechanism: Zuba pushes payout, deposit, and order status changes to your endpoint as they happen. Use polling only as a fallback (for example, to reconcile after webhook downtime). ### Polling as a fallback Each resource has its own terminal statuses: | Resource | Endpoint | Terminal statuses | | -------- | ----------------------- | ---------------------------------------------------------------------------- | | Payouts | `GET /v1/payouts/{id}` | `paid`, `failed`, `cancelled` (in flight: `created`, `queued`, `processing`) | | Deposits | `GET /v1/deposits/{id}` | `completed`, `failed`, `cancelled`, `refunded` | | Orders | `GET /v1/orders/{id}` | `completed`, `failed`, `expired`, `cancelled` | Note that `completed` is **not** a payout status: the success state for a payout is `paid`. ```typescript theme={"dark"} const PAYOUT_TERMINAL_STATUSES = ['paid', 'failed', 'cancelled']; async function waitForPayout(payoutId, { intervalMs = 5000, timeoutMs = 300000 } = {}) { const deadline = Date.now() + timeoutMs; let lastStatus = 'unknown'; while (Date.now() < deadline) { const response = await fetch(`https://api.zuba.com/v1/payouts/${payoutId}`, { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }); const payout = await handleZubaResponse(response); lastStatus = payout.status; if (PAYOUT_TERMINAL_STATUSES.includes(payout.status)) { return payout; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`Payout ${payoutId} still in flight after ${timeoutMs}ms (last status: ${lastStatus})`); } ``` Treat a `404` as permanent (the payout does not exist); transient errors while polling can simply wait for the next interval. ## Error Recovery There is no automatic route-fallback to build client-side. Recover per scenario: * **`PAYOUT_PROVIDER_UNAVAILABLE` (503, retryable)**: retry the same request later with the same `Idempotency-Key`, or call `GET /v1/payouts/available-rails?accountId=` 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. Fix the underlying cause (usually recipient details), then submit a new payout with a new `clientRef`. * **`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 3-digit CBN short code (e.g. `044`) or the 6-digit NIP long code (e.g. `000014`), and `crAccount` is the 10-digit account number. ```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: "044", 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); ``` Unique UUID for the created beneficiary Array of payment accounts with their IDs and details Required beneficiary fields vary by destination currency. Call `GET /v1/payouts/requirements` before submitting to see exactly which fields a currency needs, instead of discovering them through validation errors. ## 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: "Payment for 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); ``` `senderInfo` is optional. If omitted, your workspace is used as the business originator; 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: "044", crAccount: "1234567890" } }] }, reference: "Invoice payment", description: "Payment for 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": "Payment for 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": "044", "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 funds // - Invalid beneficiary // - Invalid bank code (must be 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: "044", 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: "First payout to new beneficiary" }) }).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. * **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)). * 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, 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", "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` 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 `requiredAmount` to the `instructions` before `fundingDeadlineAt` (mirrored on `settlement.expiresAt`): * **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, only a transfer of exactly `requiredAmount` auto-matches; anything else is credited to your balance and attributed manually. * **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** `requiredAmount` 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. ```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 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. 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. | | `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", "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. | 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. ## 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`. | | `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. **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 `requiredAmount`, `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 | `NO_PRICING_PLAN` / `CORRIDOR_NOT_ENABLED` | The pair is not enabled on your account's pricing plan. 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. | | 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`). ## Lifecycle at a glance The full sandbox loop (fund, register, pay, receive) and the two 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 · no webhook 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 / …1 / …2 Zuba-->>You: 201 · status = created Note right of Zuba: async · magic account matched:
outcome synthesised, no provider dispatch Zuba->>EP: payout.processing EP-->>Zuba: 200 OK Note over Zuba: ~3s · async settle alt 0000000000 → paid Zuba->>EP: payout.paid else 0000000001 → failed Note right of Zuba: hold reversed · failureReason =
"Sandbox: simulated provider decline" Zuba->>EP: payout.failed else 0000000002 → failed Note right of Zuba: hold reversed · failureReason =
"Sandbox: invalid beneficiary account" Zuba->>EP: payout.failed end EP-->>Zuba: 200 OK Note over Zuba,EP: non-2xx / timeout → retried with backoff · 6 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: no provider, no fraud screening, and **no webhook**. It exists purely to give you funds to spend. There are two ways to trigger one, and both exist **only in the sandbox**: * **Dashboard**: open the deposit panel for any fiat currency and click **Simulate deposit**. * **API**: `POST /v1/sandbox/deposits`, which credits the workspace of the calling token. In production the endpoint returns `404`. The 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` | Yes | Currency code (ISO 4217, case-insensitive), e.g. `USD`, `EUR`, `NGN`. | | `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, minus the webhook). 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", "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", "webhook.test"], "enabled": true, "signingSecret": "whsec_a1b2c3d4e5f6...", "signingSecretHint": "e5f6", "createdAt": "2026-06-30T10:00:00.000Z", "updatedAt": "2026-06-30T10:00:00.000Z" } ``` 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`, `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/MZN/MWK/EGP), `iban` for EUR/GBP, `phoneNumber` for mobile money. 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` with an invalid-account failure reason. | All three are created with status `created`, 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. Any other account number is treated as a regular payout. Regular sandbox payouts exercise the full payment lifecycle but their outcome depends on live sandbox conditions and is not guaranteed. Crypto (`wallet`) payouts have no magic values: the wallet address is never matched, so they always behave like regular sandbox payouts. 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. ### 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": "033", "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": "033", "crAccount": "0000000001", "accountHolderName": "Sandbox Test Recipient" } } ``` The payout moves through `processing` and transitions to `failed` after about 3 seconds, with `failureReason` set to exactly `Sandbox: simulated provider decline`. Use `0000000002` to simulate an invalid-account failure (`failureReason`: `Sandbox: invalid beneficiary account`). Both strings are stable, so your tests can match on them. ### 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" } } ``` ### 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 three magic numbers. The trailing digits match the account-number scheme: **`…00` → paid, `…01` → failed, `…02` → invalid account.** | Country | Paid | Failed | Invalid account | | ------------------ | ---------------- | ---------------- | ---------------- | | Ghana (GH) | `+233200000000` | `+233200000001` | `+233200000002` | | Côte d'Ivoire (CI) | `+2250500000000` | `+2250500000001` | `+2250500000002` | | Senegal (SN) | `+221701234500` | `+221701234501` | `+221701234502` | | Mali (ML) | `+22365012300` | `+22365012301` | `+22365012302` | | Burkina Faso (BF) | `+22670123400` | `+22670123401` | `+22670123402` | | Benin (BJ) | `+2290195123400` | `+2290195123401` | `+2290195123402` | | Togo (TG) | `+22890112300` | `+22890112301` | `+22890112302` | | Cameroon (CM) | `+237671234500` | `+237671234501` | `+237671234502` | 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. Cameroon (CM) payouts additionally require the sender's phone number on the payout request via `senderInfo`. Once `senderInfo` is present its identity fields are validated too, so send a complete object, e.g. `"senderInfo": { "type": "individual", "firstName": "Ada", "lastName": "Eze", "phoneNumber": "+237650000000" }` (a business sender instead requires `companyName`, `registrationNumber`, and `country`). Without `senderInfo.phoneNumber`, 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, or `+233200000002` for an invalid-account failure, exactly as with the bank account numbers above. ### Sandbox-only payout currencies Four additional payout currencies exist **only in the sandbox**: **ZMW** (Zambia), **MZN** (Mozambique), **MWK** (Malawi) and **EGP** (Egypt). Use them to build and test flows for these markets before they go live. In production a payout to one of these currencies is rejected at creation. Payouts to these currencies **always settle synthetically**: no funds move anywhere. The magic account numbers above drive `paid` / `failed` / invalid-account outcomes as usual, and **any other account number settles as `paid`** (unlike regular sandbox corridors, whose non-magic outcome depends on live sandbox conditions). Fund them cross-currency from any 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 `MZN`, `MWK`, and `EGP`, with the magic values going in `accountNumber`. ## 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 magic payout settles about 3 seconds after entering `processing`, but you should not assume a fixed delay. It delivers the full sequence: 1. `payout.processing` 2. `payout.paid` **or** `payout.failed` 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 5 times (6 attempts in total) with exponential backoff; a delivery counts as successful on any `2xx` within 30 seconds. See [Delivery and Retries](/guides/webhooks#delivery-and-retries). ## Quotes Quotes price a currency pair before you commit to it, and they work in the sandbox exactly as in production: `POST /v1/quotes` mints a single-use held quote and locks the rate until `expiresAt`. The `intent` field decides which executor can consume it. There are no magic values here; a quote is priced against the live sandbox rate feeds, and minting one is free, so you can also use the endpoint standalone for rate discovery and simply let unused quotes expire. ```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`. 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) | `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). | | `2222.22` | The order is rejected by the desk and lands `failed` (`execution_failed`), with the held funds returned to your balance. | 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 `toAmount` to the beneficiary amount, then pass the id as `quoteId` on `POST /v1/payouts`. 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, `accountNumber` for USD and the sandbox-only currencies, `iban` for EUR/GBP, `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 funds were credited back. Unlike `payout.failed` (a pre-settlement failure), this payout did originally succeed: do **not** automatically re-issue it. *(Not yet delivered, pending a backend enum migration. You can register a handler now, but it will only start firing once that migration ships.)* * `payout.cancelled` - Payout was cancelled downstream while processing. Cancellations you initiate via `POST /v1/payouts/{id}/cancel` are confirmed synchronously by the cancel response and do **not** emit a webhook #### 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 validation or funding before processing (for example, insufficient balance) emits `payout.failed` as its first and only event: ```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 (pre-processing failure) processing --> paid : payout.paid processing --> failed : payout.failed pending --> cancelled : no webhook (synchronous cancel) processing --> cancelled : payout.cancelled paid --> failed : payout.reverted (settled, then returned) paid --> [*] failed --> [*] cancelled --> [*] ``` 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 * `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` ### 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", "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" } } ``` | Field | Type | Description | | ----------- | ------- | --------------------------------------------- | | `id` | string | Unique event ID (`evt_` prefix) | | `type` | string | Event type | | `createdAt` | string | ISO 8601 timestamp of event creation | | `test` | boolean | `true` if sent from the test webhook endpoint | | `data` | object | Event-specific payload | ### 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`, `EURC`) for crypto payouts | | `status` | string | Payout status after the transition | | `completedAt` | string \| `null` | ISO 8601 timestamp set when the payout reaches `paid` (settlement instant) or `failed` (failure instant); `null` while the payout is in flight and on `cancelled` | **`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 | | `settlement` | object \| `null` | Funding instructions for settlement-funded orders (`settlementId`, `method`, `requiredAmount`, `currency`, `reference`, `status`, `requirements.proofOfPayment`, `instructions`, `expiresAt`); `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 | 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) 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. 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 Ensure webhooks can be safely retried: ```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; } } ``` ### 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 **5 times** (6 attempts in total) with exponential backoff. 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 | Approximate delay | | ------- | ----------------- | | 1 | Immediate | | 2 | \~1 minute | | 3 | \~2 minutes | | 4 | \~4 minutes | | 5 | \~8 minutes | | 6 | \~16 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}`. 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 Send payments worldwide via international transfers, SWIFT, US domestic rails (ACH and Fedwire), local bank transfers, mobile money, and crypto wallets Accept payments through manual bank deposits Handle multiple currencies with real-time conversion and balance management 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 Sign up for a Zuba account and obtain your API keys from the dashboard Review our API reference and test endpoints in the interactive documentation Follow the [quickstart guide](/quickstart) to send your first payout 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. 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 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 ``` Add to `~/.codex/config.toml`: ```toml theme={"dark"} [mcp_servers.zuba-docs] url = "https://docs.zuba.com/mcp" ``` Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per project): ```json theme={"dark"} { "mcpServers": { "zuba-docs": { "url": "https://docs.zuba.com/mcp" } } } ``` Add to `.vscode/mcp.json`: ```json theme={"dark"} { "servers": { "zuba-docs": { "type": "http", "url": "https://docs.zuba.com/mcp" } } } ``` 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. 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 | | --------- | -------- | ---------- | | Burst | 10 | per second | | Sustained | 100 | per minute | | Hourly | 1,000 | per hour | 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": "044", "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: '044', 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': '044', '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", "044"); 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); ``` `bankCode` accepts either the 3-digit CBN short code (e.g. `044`) or the 6-digit NIP long code; `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); ``` 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": "Payment for 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: 'Payment for 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': 'Payment for 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", "Payment for 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); ``` `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": "Payment for 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: 'Payment for 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': 'Payment for 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", "Payment for 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 Learn about secure authentication methods Set up real-time payout notifications Process multiple payouts efficiently 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 US, Asia, and stablecoin rails: collections in, FX conversion, payouts out, all behind one API and dashboard. | Currency | Name | Rails & networks | | :-------------------------- | :------------------------ | :--------------------------------------------------- | | AED **AED** | UAE Dirham | UAEFTS, SWIFT | | AUD **AUD** | Australian Dollar | BECS, SWIFT | | BHD **BHD** | Bahraini Dinar | SWIFT | | BRL **BRL** | Brazilian Real | PIX | | CAD **CAD** | Canadian Dollar | EFT, SWIFT | | CHF **CHF** | Swiss Franc | SWIFT | | CNY **CNY** | Chinese Yuan | SWIFT, Alipay | | DKK **DKK** | Danish Krone | SWIFT | | EUR **EUR** | Euro | SEPA (IBAN) | | GBP **GBP** | British Pound Sterling | Faster Payments (IBAN) | | GHS **GHS** | Ghanaian Cedi | Bank transfer, mobile money | | HKD **HKD** | Hong Kong Dollar | FPS, SWIFT | | IDR **IDR** | Indonesian Rupiah | BI-FAST, SWIFT | | INR **INR** | Indian Rupee | IMPS / NEFT, SWIFT | | JPY **JPY** | Japanese Yen | SWIFT | | KRW **KRW** | South Korean Won | KFTC, SWIFT | | MXN **MXN** | Mexican Peso | SPEI | | MYR **MYR** | Malaysian Ringgit | SWIFT | | NGN **NGN** | Nigerian Naira | Bank transfer (NIP) | | NZD **NZD** | New Zealand Dollar | SWIFT | | PHP **PHP** | Philippine Peso | InstaPay / PESONet, SWIFT | | PLN **PLN** | Polish Zloty | SWIFT | | SEK **SEK** | Swedish Krona | SWIFT | | SGD **SGD** | Singapore Dollar | FAST, SWIFT | | THB **THB** | Thai Baht | PromptPay, SWIFT | | TRY **TRY** | Turkish Lira | SWIFT | | TWD **TWD** | New Taiwan Dollar | SWIFT | | USD **USD** | US Dollar | ACH, Fedwire, SWIFT, CHATS, CFXPS, SPID | | VND **VND** | Vietnamese Dong | SWIFT | | XAF **XAF** | Central African CFA Franc | Mobile money, Cameroon; bank transfer (early access) | | XOF **XOF** | West African CFA Franc | Mobile money, 6 markets | | ZAR **ZAR** | South African Rand | SWIFT | | USDC **USDC** | USD Coin | Ethereum, Solana | | USDT **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), 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).