Skip to main content
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:
See 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:

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:
The response returns immediately with 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:
Create the endpoint and subscribe to the events you care about:
The response contains the signing secret once. Store it now, you cannot retrieve it again:
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.
Subscribable event types: payout.processing, payout.paid, payout.failed, payout.reverted (registrable but not yet delivered), payout.cancelled, order.created, order.awaiting_funds, order.funds_received, order.completed, order.failed, order.expired, virtual_account.active, virtual_account.failed, application.under_review, application.more_info_requested, application.approved, application.rejected, account.created, webhook.test. What each event carries and when it fires is covered in Webhook Notifications.

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:
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 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. 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 createdprocessing → 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

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

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. 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:
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:
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.
  • 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:
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.

Virtual accounts

Virtual-account issuance is fully simulated in the sandbox: POST /v1/virtual-accounts never opens a real bank account and never depends on any downstream banking service being available. The returned details are synthetic: IBANs carry valid check digits so your format validation passes, and every simulated IBAN contains the marker SNDB so it is recognisable at a glance. Simulated account numbers are not payable: fund your workspace with the deposit simulator instead of transferring to them. These currencies can be issued, and the lifecycle mirrors production: Any other currency returns 422. For an asynchronous currency the create call returns 201 with status: "pending" and the account activates a few seconds later. Subscribe to the virtual_account.active webhook for the completion signal (see Webhooks), then fetch the issued details with GET /v1/virtual-accounts; if you must poll instead, don’t assume a fixed delay. Synchronous NGN needs no completion signal: the create response already carries the account number. Where a currency is marked self-serve in the sandbox only, production reaches the same account by a different route (NGN is provisioned for you during onboarding review) and this endpoint returns 422 there. USD is issuable in production, but its verification is not instant there: the sandbox approves it as the call is made, while production reviews it and the account can stay pending for days. Build against the virtual_account.active webhook rather than the timing you see in the sandbox, and expect virtual_account.failed for a USD account the review refuses. Issuance requires an approved workspace application and opens the account under your workspace’s legal name; a sandbox workspace created through quick-create satisfies this for every issuable currency. To issue an account owned by a sub-account, send its id in the Zuba-Account-Id header; deposits to that account then settle into the sub-account’s balance. NGN is the exception: those accounts are always held by the master workspace, so an NGN request naming a sub-account returns 422.

Simulating a rejection

Some currencies’ accounts are subject to an approval step that can refuse the account. To exercise your rejection handling, use a workspace whose legal name contains SANDBOX-REJECT (matched case-insensitively, e.g. Acme SANDBOX-REJECT Ltd). Issuance for that workspace is refused instead of approved:
  • XOF/XAF/GHS: the account is created pending, then the approval workflow rejects it: a virtual_account.failed webhook fires, and the account never becomes active and never appears in GET /v1/virtual-accounts. A repeat create for the same currency then returns 409 VIRTUAL_ACCOUNT_DEACTIVATED, mirroring production, where a refused account is parked for operator review. Use a fresh currency (or a fresh workspace) for another rejection round.
  • EUR/GBP/USD/NGN: issuance is declined and the create call fails with a 502 provider error.
Any other legal name is always approved.

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.
Every intent returns the same shape: the pair, the amount received, the total debited, the all-in rate, and the expiry.
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: 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, execute an in-wallet conversion by consuming the convert quote above:
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; 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:
In a sell currency with no minor unit — XOF, XAF, JPY, KRW, VND, UGX — the triggers are the whole units 1111 and 2222. A quote holds its amounts at the sell currency’s own precision, so 1111.11 XOF is quoted as 1111 and reaches the desk as the whole-unit trigger. Sending either form gives the same outcome; reading totalDebitAmount back off the quote tells you which value the order will carry.
Both fire the full webhook sequence (order.created, order.funds_received, then order.completed or order.failed). The magic amounts apply on either funding path: a balance-funded order short-circuits at creation, and a settlement-funded order short-circuits the moment its deposit matches (so the sequence gains order.awaiting_funds between order.created and order.funds_received). Any other amount stays in funds_received awaiting desk execution, exactly as in production. To pin the sell amount precisely, fix fromAmount on the order quote:

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 drive the outcome exactly as for an auto-priced payout, so a quote-first payout can still land a guaranteed paid or failed.

Tips

  • Use distinct clientRef values per test run so polling and webhook handlers can correlate requests cleanly.
  • Don’t assume status transitions are instant. The create response returns created; both the move to processing and the terminal transition happen asynchronously. Tests that expect immediate state change will be flaky: wait on the webhook or poll the GET endpoint.
  • Test failures too. It’s easy to verify the happy path; make sure your error-handling code is exercised by the documented failure values as well.
  • The beneficiary identifier is what matters. Only the identifier field (crAccount for NGN/GHS/KES/UGX/XAF/XOF, accountNumber for USD and the sandbox-only 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