payout.* webhooks arrive. This page walks the whole loop.
Everything here uses the sandbox base URL and a sandbox token:
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:
| 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. |
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.cancelled, account.created, webhook.test.
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 in every currency and corridor. For bank transfers that identifier is the account number (NGN, USD, GHS, EUR, GBP, …); 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/KES/ZAR/XOF/XAF, accountNumber for USD/ACH, 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. |
processing and transition asynchronously to their terminal state, so you can exercise the full lifecycle (including the processing webhook) 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.
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": "processing". After the asynchronous transition you will 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 a few seconds. The failureReason field on the payout will describe the failure. Use 0000000002 to simulate an invalid-account failure.
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.
| 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 |
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. A guaranteed-success GHS mobile payout:
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 drivefailed / 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.
Deterministic orders
Orders (POST /v1/orders, see Trade Desk 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. |
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, request the order quote with fromAmount fixed:
Step 4 — Receive the webhooks
Payouts always start inprocessing and transition asynchronously to a terminal state (paid or failed). A magic payout settles after a few seconds, 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.
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.
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 |
- 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. The flow is covered end-to-end in Trade Desk Orders. The sandbox twist is the magic sell amounts in Deterministic orders above: fix fromAmount to 1111.11 or 2222.22 on the quote to pin the order’s sell amount and drive it to a guaranteed terminal state.
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 the
processing→ terminal transition is instant. 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/accountNumber/ibanfor bank,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