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:
export ZUBA_TEST_TOKEN="<your sandbox bearer token>"
export ZUBA_API="https://api.sandbox.zuba.com"
See Authentication for how to obtain a token. The magic account numbers and the deposit simulator only behave this way in the sandbox; in production both go through real account validation and settlement.

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.
  • APIPOST /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:
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"
  }'
FieldRequiredDescription
amountYesAmount to credit, as a major-unit decimal string (e.g. "1000.00"). Must be greater than zero.
currencyYesCurrency code (ISO 4217, case-insensitive) — e.g. USD, EUR, NGN.
clientRefNoIdempotency key, unique per workspace. A value is generated when omitted.
narrationNoSimulated transfer narration, as a bank would pass it through. Include an order settlement reference (SETTLE-…) to exercise automatic settlement matching.
The response returns immediately with status: "processing":
{
  "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:
ngrok http 3000
# → https://abc123.ngrok.io
Create the endpoint and subscribe to the events you care about:
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:
{
  "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": "f6...",
  "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.
Subscribable event types: 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 a webhook.test event to the registered URL immediately:
curl -X POST "$ZUBA_API/v1/webhooks/123e4567-e89b-12d3-a456-426614174000/test" \
  -H "Authorization: Bearer $ZUBA_TEST_TOKEN"
{ "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 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 numberResolved holder nameOutcome
0000000000Sandbox: paidThe payout transitions to paid.
0000000001Sandbox: failedThe payout transitions to failed.
0000000002Sandbox: invalid accountThe payout transitions to failed with an invalid-account failure reason.
All three start in 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

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 initial response will include "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 the crAccount to 0000000001 to assert your error-handling path:
{
  "data": {
    "bankCode": "033",
    "crAccount": "0000000001",
    "accountHolderName": "Sandbox Test Recipient"
  }
}
The payout starts in 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 in accountNumber:
{
  "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.
CountryPaidFailedInvalid 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. A guaranteed-success GHS mobile payout:
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 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:
{
  "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.

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 amountOutcome
1111.11The order runs the full desk flow and lands completed (with a sandbox-… UETR).
2222.22The 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, request the order quote with fromAmount fixed:
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"
  }'
Then create the order with the returned quote id and a beneficiary holding an active account in the buy currency.

Step 4 — Receive the webhooks

Payouts always start in processing 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:
  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.
  • PollingGET /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:
curl "$ZUBA_API/v1/webhooks/123e4567-e89b-12d3-a456-426614174000/deliveries" \
  -H "Authorization: Bearer $ZUBA_TEST_TOKEN"
{
  "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 with exponential backoff; a delivery counts as successful on any 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.
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.
{
  "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"
}
Which side of the pair you fix depends on the intent, and each intent is consumed by exactly one executor:
intentAmount fieldConsumed by
payouttoAmount (the beneficiary amount)POST /v1/payouts, as quoteId
convertfromAmount (the source debit)POST /v1/conversions, as quoteId
orderfromAmount or toAmount, exactly onePOST /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, execute an in-wallet conversion by consuming the convert quote above:
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. 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 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 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 / iban for bank, 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