payout.* webhooks arrive. This page walks the whole loop.
Everything here uses the sandbox base URL and a sandbox token:
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 returns404.
clientId:
The response returns immediately with
status: "processing":
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: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 awebhook.test event to the registered URL immediately:
"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 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
"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 thecrAccount to 0000000001 to assert your error-handling path:
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 inaccountNumber:
Mobile money
Mobile money payouts carry the beneficiary as a phone number, so the magic value goes in thephoneNumber 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.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 drivepaid / 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:
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 statuscreated, 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:
payout.processingpayout.paidorpayout.failed
- Webhooks: the recommended approach. Treat the webhook as the source of truth. Each delivery is a JSON POST carrying
X-Zuba-SignatureandX-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/:idand check thestatusfield. 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: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 containsSANDBOX-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 createdpending, then the approval workflow rejects it: avirtual_account.failedwebhook fires, and the account never becomes active and never appears inGET /v1/virtual-accounts. A repeat create for the same currency then returns409 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 a502provider error.
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.
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
expiresAtrather 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 withQUOTE_INTENT_MISMATCH.
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: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 withintent: "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.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 withintent: "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
clientRefvalues 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 toprocessingand 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 (
crAccountfor NGN/GHS/KES/UGX/XAF/XOF,accountNumberfor USD and the sandbox-only currencies,ibanfor EUR/GBP,phoneNumberfor 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: endpoint setup, signature verification, retries, and idempotency
- Your First Payout: beneficiaries, routes, and the payout request shape
- Error Handling: robust handling of failure paths