Skip to main content
Orders are Zuba’s channel for large currency transfers that are executed manually by our trade desk rather than dispatched straight through to a payment rail. You request a firm quote, create the order against it, fund it, and Zuba’s desk sources liquidity and delivers to your beneficiary within an execution window stated on the order. The flow is deliberately different from payouts in three ways:
  • Quote-first. Every order starts from a firm quote (POST /v1/quotes with intent: "order"). There is no auto-priced path.
  • Fund-first. The desk executes only against funds that are already secured. If your Zuba balance covers the order it is funded at creation; otherwise the order returns funding instructions and waits for your transfer. Zuba never extends credit, and nothing moves outbound until the order is funded.
  • Manually executed. A human trading team fills the order and a second operator approves the execution before any funds move. Orders complete within the execution window returned on the order, not in seconds.
If you need automated, straight-through delivery for everyday amounts, use payouts instead. Amounts above a payout corridor’s cap are served by this channel.

Orders vs payouts

PayoutsOrders
ExecutionAutomated, straight-throughManual, by Zuba’s trade desk
PricingQuote or auto-priced; rate plus a fixed feeAlways quote-first; a single all-in rate, no separate fee
FundingYour balance, at creationYour balance, or a per-order transfer against funding instructions
Typical sizeEveryday amounts, subject to corridor capsLarge transfers, including amounts above payout corridor caps
SpeedSeconds to minutesWithin the execution window on the order
CancellationWhile still pendingAny time before the desk approves execution

Prerequisites

  • Orders enabled on your account. Orders are enabled per account, configured with the currency pairs (corridors) and transaction limits agreed during onboarding. Contact your account manager to get set up.
  • A beneficiary holding an active account in the buy currency (see Beneficiaries).
  • Valid API credentials (see Authentication).
The examples below use the sandbox host https://api.sandbox.zuba.com. In production, swap it for https://api.zuba.com; everything else is identical.

Step 1: Get a firm quote

Request a quote with intent: "order". Fix exactly one side of the trade:
  • fromAmount fixes what you sell (the total debit), or
  • toAmount fixes what your beneficiary receives.
curl -X POST "https://api.sandbox.zuba.com/v1/quotes" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "intent": "order",
    "fromCurrency": "USDT",
    "toCurrency": "NGN",
    "toAmount": "1500000.00"
  }'
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "fromCurrency": "USDT",
  "toCurrency": "NGN",
  "targetAmount": "1500000.0000",
  "totalDebitAmount": "1006.5000",
  "rate": "0.00067100",
  "expiresAt": "2026-07-08T12:00:30Z"
}
Three things to know about order quotes:
  • The rate is all-in. totalDebitAmount is exactly what the order costs you; there is no separate fee added at order creation. rate is expressed as fromCurrency per 1 toCurrency.
  • The quote is firm. The rate you see is the rate your order executes at, regardless of market movement while the desk works the order.
  • The quote is single-use and short-lived. It typically expires within about 30 seconds; always read expiresAt rather than assuming a TTL. Create the order before it expires, or request a fresh quote.
A quote request is rejected with UNSUPPORTED_CORRIDOR if the pair is not enabled for orders on your account, or with an amount error if it is below the corridor minimum.

Step 2: Create the order

Create the order with the quote id and a beneficiary. The Idempotency-Key header is required.
curl -X POST "https://api.sandbox.zuba.com/v1/orders" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4d9f6c2a-8f6e-4b1a-9d3e-2c7b5a1e0f48" \
  -d '{
    "quotationId": "550e8400-e29b-41d4-a716-446655440000",
    "beneficiaryId": "cc82fa1d-fc7a-478c-a734-d3bce40464e7",
    "clientRef": "ORD-2026-0001",
    "purpose": "Supplier settlement"
  }'
Creation consumes the quote (it cannot be used again) and picks a funding path based on your available balance in the sell currency: If your balance covers fromAmount, it is debited into a hold reserved for this order in the same step, and the order is created directly in funds_received, queued for the desk:
{
  "id": "9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
  "quoteId": "550e8400-e29b-41d4-a716-446655440000",
  "beneficiaryId": "cc82fa1d-fc7a-478c-a734-d3bce40464e7",
  "status": "funds_received",
  "fromCurrency": "USDT",
  "toCurrency": "NGN",
  "fromAmount": "1006.5000",
  "toAmount": "1500000.0000",
  "rate": "0.00067100",
  "clientRef": "ORD-2026-0001",
  "purpose": "Supplier settlement",
  "fundingDeadlineAt": "2026-07-08T12:30:00Z",
  "executionDeadlineAt": "2026-07-08T18:00:00Z",
  "fundedAt": "2026-07-08T12:00:05Z",
  "createdAt": "2026-07-08T12:00:05Z",
  "updatedAt": "2026-07-08T12:00:05Z"
}
Otherwise, no money moves: the order is created in awaiting_funds and the response carries a settlement block with the funding instructions, the exact amount to transfer, and a unique reference:
{
  "id": "1b7e4a90-2c5d-4e8f-9a0b-3c6d7e8f9a0b",
  "quoteId": "6a1f9511-f3ac-52e5-b827-557766551111",
  "beneficiaryId": "cc82fa1d-fc7a-478c-a734-d3bce40464e7",
  "status": "awaiting_funds",
  "fromCurrency": "XOF",
  "toCurrency": "USD",
  "fromAmount": "6500000.0000",
  "toAmount": "10000.0000",
  "rate": "650.00000000",
  "clientRef": "ORD-2026-0002",
  "settlement": {
    "settlementId": "8c2d5e7f-1a3b-4c5d-8e9f-0a1b2c3d4e5f",
    "method": "virtual_account",
    "requiredAmount": "6500000.0000",
    "currency": "XOF",
    "reference": "SETTLE-4F7A2C91B0",
    "status": "open",
    "instructions": {
      "bankName": "Example Bank",
      "accountNumber": "00012345678",
      "accountName": "Zuba Collections",
      "note": "Include the settlement reference in the transfer narration."
    },
    "expiresAt": "2026-07-08T12:30:00Z"
  },
  "fundingDeadlineAt": "2026-07-08T12:30:00Z",
  "createdAt": "2026-07-08T12:00:05Z",
  "updatedAt": "2026-07-08T12:00:05Z"
}
Either way the quoted rate is locked: funding the order within the window executes at the rate on the order, regardless of market movement in between. Idempotency. Retrying the same request returns the original order rather than creating a duplicate. Two independent guards apply: the quote is single-use (a retry carrying the same quotationId returns the order it already created), and clientRef is unique per account (reusing one on a different order returns 409 CLIENT_REF_ALREADY_USED).

Step 3: Fund the order

Balance-funded orders skip this step; they are already funds_received. For a settlement-funded order, transfer requiredAmount to the instructions before fundingDeadlineAt (mirrored on settlement.expiresAt):
  • Bank transfer (method: "virtual_account"): send to the account in instructions and include the reference in the transfer narration wherever your bank supports one. A referenced transfer attributes to the order at any amount: a short payment sits on your balance until you top it up under the same reference, and an overpayment attributes with the excess left on your balance. Without the reference, only a transfer of exactly requiredAmount auto-matches; anything else is credited to your balance and attributed manually.
  • Crypto transfer (method: "wallet_address"): send exactly requiredAmount to your own deposit address from instructions.addresses (any listed network). The receiving address identifies you, so no reference is needed.
When the transfer is matched, the funds are held into the order, the status moves to funds_received, executionDeadlineAt is set, and the order.funds_received webhook fires. Matching is automatic on referenced bank transfers and crypto deposits; unreferenced bank transfers can take longer when manual attribution is needed. If the funding window closes first, the order lands expired (webhook order.expired) and the settlement is closed. Nothing was held, so there is nothing to refund; a transfer that arrives after expiry is simply credited to your balance. Re-quote and create a fresh order, which your now-funded balance can fund directly.

Step 4: Wait for desk execution

This is the part that makes orders different: there is nothing to call. Your order is now in a queue worked by Zuba’s trading team. An operator fills the order at or better than your quoted rate, and a second operator independently reviews and approves the execution before any funds move. This dual-control step is why orders are not instant. What you can rely on:
  • The order completes or fails by executionDeadlineAt. If the desk has not executed by then, the order automatically fails with reason review_expired and your held funds are returned. You never need to chase a stuck order.
  • While the desk works, the order reports funds_received. Once execution is approved it briefly reports executing; at that point it can no longer be cancelled.
  • Desk internals (venues, fills, operators) are never exposed on the API. Your contract is the quoted rate and the execution window.
Track progress by webhook (recommended) or by polling:
curl "https://api.sandbox.zuba.com/v1/orders/9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Step 5: Completion

When the desk delivers to your beneficiary, the order lands completed and the order.completed webhook fires. For orders delivered over a wire rail, the response and webhook carry the outbound wire reference (uetr) for your beneficiary’s bank to trace the payment.
{
  "id": "9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
  "status": "completed",
  "uetr": "e8b1a9d2-7c4f-4a2b-8e6d-1f3a5b7c9d0e",
  "completedAt": "2026-07-08T14:21:07Z",
  "...": "..."
}
The hold is settled: you paid exactly fromAmount at the quoted rate, and the ledger shows the order’s legs against the order id. See Ledger for how to reconcile.

Order lifecycle

StatusMeaning
createdThe order exists but no funding path has resolved yet. Transient: creation moves straight on to funds_received or awaiting_funds, so polling never observes it (the order.created webhook still fires).
awaiting_fundsFunding instructions are issued and the order is waiting for your transfer. No funds are held.
funds_receivedThe source amount is held and the order is queued for the desk.
executingThe desk has approved execution. The order can no longer be cancelled.
completedDelivered to your beneficiary. uetr is set for wire deliveries. Terminal.
failedThe desk could not execute, or the execution window lapsed. failureReason is set and the held funds are returned to your balance in full. Terminal.
expiredThe funding window closed before the order was funded. Nothing was held; late-arriving funds credit your balance. Terminal.
cancelledYou cancelled before execution was approved. Any held funds are returned in full. Terminal.

Webhooks

Order webhooks share the envelope, signing, and retry behaviour described in Webhook Notifications. Subscribe your endpoint to the order.* events:
EventFires when
order.createdThe order is created. Arrives together with order.funds_received or order.awaiting_funds.
order.awaiting_fundsFunding instructions are issued; the payload’s settlement block carries the account or address, amount, and reference.
order.funds_receivedFunds are held and the order is queued for the desk.
order.completedThe desk delivered; uetr present for wire deliveries.
order.failedThe order failed; failureReason set, held funds returned.
order.expiredThe funding window closed before the order was funded.
There is no webhook for executing (desk review is internal) and none for cancelled (cancellation is your own synchronous API call). Every event for a settlement-funded order carries the settlement block, so you can correlate by reference at any point in the lifecycle; on balance-funded orders settlement is null. Example delivery:
{
  "id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "type": "order.completed",
  "createdAt": "2026-07-08T14:21:08.000Z",
  "test": false,
  "data": {
    "id": "9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
    "clientRef": "ORD-2026-0001",
    "status": "completed",
    "fromCurrency": "USDT",
    "toCurrency": "NGN",
    "fromAmount": "1006.5000",
    "toAmount": "1500000.0000",
    "rate": "0.00067100",
    "failureReason": null,
    "uetr": "e8b1a9d2-7c4f-4a2b-8e6d-1f3a5b7c9d0e",
    "settlement": null,
    "fundingDeadlineAt": "2026-07-08T12:30:00.000Z",
    "executionDeadlineAt": "2026-07-08T18:00:00.000Z",
    "fundedAt": "2026-07-08T12:00:05.000Z",
    "completedAt": "2026-07-08T14:21:07.000Z",
    "createdAt": "2026-07-08T12:00:05.000Z"
  }
}

Cancelling an order

You can cancel an order at any point before the desk approves execution:
curl -X POST "https://api.sandbox.zuba.com/v1/orders/9f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d/cancel" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
A successful cancel returns the order with status: "cancelled". While the order is awaiting_funds nothing is held, so cancellation simply closes it; after funds_received the held funds are returned to your balance in full. Retrying a successful cancel returns the already-cancelled order. Once execution has been approved (executing or any terminal status), cancel returns 409 ORDER_NOT_CANCELLABLE. Because approval can happen at any moment while the order is funds_received, treat cancellation as a race you can lose: a 409 means the trade is executing and will complete at the quoted rate.

Failures and refunds

A failed order is terminal and always refunds the full held amount to your balance automatically; there is no partial execution and nothing to claim. failureReason carries a fixed taxonomy:
failureReasonMeaningWhat to do
execution_failedThe desk could not execute the order.Funds are back on your balance. Request a fresh quote and create a new order, or contact support if it recurs.
review_expiredThe execution window lapsed before the desk completed the order.Same: funds are returned; re-quote and retry.
A failed order is a normal, fully-resolved outcome, not a stuck state: the terminal status and the refund arrive together. Refunds always land on your Zuba balance, never as an outbound transfer back to you; withdraw them with a payout if you need them off-platform. Reconcile by matching the hold and its reversal against the order id in your ledger history.
An expired order held nothing, so there is no refund; any transfer that arrives after expiry is credited to your balance.

Listing orders

GET /v1/orders returns your orders newest-first with cursor pagination, filterable by status and a from/to creation-time window:
curl "https://api.sandbox.zuba.com/v1/orders?status=completed&limit=20" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Use distinct clientRef values per order; they surface on the order, the webhooks, and your ledger history, and make reconciliation trivial.

Testing in the sandbox

The sandbox exercises the real order machinery end to end. Two mechanisms cover the parts that involve other actors in production: Deterministic desk outcomes. Orders are executed manually in production, so two magic sell amounts drive a funded order through the real desk workflow to a terminal state without a human operator. Any other amount stays in funds_received awaiting manual execution, exactly as in production.
Sell amountOutcome
1111.11Lands completed, with a sandbox- prefixed uetr.
2222.22Lands failed (execution_failed), with the held funds returned to your balance.
The match is on the order’s source (sell) amount, is numeric (1111.11 matches 1111.1100), and works in any sell currency. Simulated settlement funding. The sandbox deposit endpoint accepts a narration field; include an order’s settlement reference in it to exercise the real automatic matcher. A complete settlement-funded run, using a fiat sell currency (settlements in crypto sell currencies fund through your own deposit addresses by exact amount, so the narration mechanism below does not apply to them):
  1. Quote with the sell side pinned to a magic amount: {"intent": "order", "fromCurrency": "USD", "toCurrency": "NGN", "fromAmount": "1111.11"}.
  2. Create the order with the returned quote id and a beneficiary holding an active account in the buy currency. With no balance in the sell currency, the order lands awaiting_funds and returns the settlement block (order.created + order.awaiting_funds fire).
  3. Simulate your transfer: POST /v1/sandbox/deposits with amount set to requiredAmount, currency set to the sell currency, and narration containing the settlement reference. The matcher attributes it, holds the funds, and fires order.funds_received.
  4. The magic sell amount then drives the desk flow: order.completed (or order.failed for 2222.22) arrives shortly after. Do not assume a fixed delay; wait on the webhook or poll.
To test the balance-funded path instead, fund your sandbox balance first and create the order; it goes straight to funds_received. To test cancellation, use a non-magic amount (the order will sit in awaiting_funds or funds_received) and call the cancel endpoint. To test expiry, create a settlement-funded order and let the funding window lapse. See Sandbox Testing for the full sandbox reference.

Error reference

StatusCodeCause
400Idempotency-Key header is requiredMissing the required header on create.
400QUOTE_NOT_FOUND / QUOTE_EXPIRED / QUOTE_ALREADY_USED / QUOTE_INTENT_MISMATCHThe quote is unknown, past expiresAt, already consumed, or was not minted with intent: "order". Request a fresh order quote.
400UNSUPPORTED_CORRIDORThe currency pair is not enabled for orders on your account.
400BENEFICIARY_NOT_FOUNDThe beneficiary id does not exist on your account.
400NO_SUITABLE_ACCOUNT_FOR_ORDER_CURRENCYThe beneficiary has no active account in the buy currency.
403insufficient_scopeOrders are not enabled on your account.
409CLIENT_REF_ALREADY_USEDThe clientRef was already used on a different order.
409ORDER_NOT_CANCELLABLECancel arrived after execution was approved.
422INSUFFICIENT_BALANCEYour balance does not cover fromAmount and settlement funding is not available for the sell currency on your account.
422limit exceededThe order exceeds your per-transaction or monthly limit. Limits are agreed at onboarding; contact your account manager to raise them.

Next steps