> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zuba.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 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="<your sandbox bearer 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 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:

```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
    Note right of Zuba: magic account matched —<br/>outcome synthesised, no provider dispatch
    Zuba-->>You: 201 · status = processing
    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 =<br/>simulated provider decline
        Zuba->>EP: payout.failed
    else 0000000002 → failed
        Note right of Zuba: hold reversed · failureReason =<br/>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. |

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": "f6...",
  "createdAt": "2026-06-30T10:00:00.000Z",
  "updatedAt": "2026-06-30T10:00:00.000Z"
}
```

<Warning>
  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).
</Warning>

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:

```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 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](#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. |

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

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

```json theme={"dark"}
{
  "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`:

```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. 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 `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`.

### Deterministic orders

Orders (`POST /v1/orders`, see [Trade Desk Orders](/guides/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, request the order quote with `fromAmount` fixed:

```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"
  }'
```

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](/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 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"
}
```

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. The flow is covered end-to-end in [Trade Desk Orders](/guides/orders). The sandbox twist is the magic sell amounts in [Deterministic orders](#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](#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 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

* [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
