Skip to main content
This walkthrough drives one virtual account through its whole life against the sandbox: create and verify a user, open the virtual account and watch it activate on its own, simulate a deposit to exercise your notification handling, price a quotation, create an outbound payout, and watch the deposit and payout complete. It stitches together the Quickstart, Creating users, Itemized quotations, and Payouts, quotations, and recipients guides into a single testing script. Every example uses the sandbox base URL https://api.balampay.com/sandbox; in production, drop the /sandbox prefix. Every authenticated call carries both x-api-key and Authorization: Bearer <access token> (see Authentication).
How the sandbox behaves — read this first. Three sandbox behaviors shape every test below:
  • Verification is automatic. A user that is complete for a product verifies on its own — usually within seconds — with no verification call for you to make. It can instead route to manual review (status: REVIEW).
  • Virtual accounts activate automatically. The sandbox polls provider status every 15 seconds, so a new VA becomes funds-ready in about a minute and fires virtual_account.activated on its own.
  • A simulated deposit credits the VA balance. On the fiat wire rail, simulate-deposit fires the deposit event and webhook and credits the virtual account’s available balance; a payout then debits that same balance, so deposit → balance → payout is one connected cycle (GET /v1/virtual-accounts/{id}/balance reflects it, possibly after a few seconds). A VA may also open with a provider-side sandbox float, so a payout can succeed before you simulate a deposit — always confirm the balance covers the amount first. Balance behavior is not uniform across every rail, so treat the virtual_account.deposit_funds_received webhook as the rail-independent signal. See Known limitations.
1

Authenticate and pin your version

Exchange your credentials for a bearer token, then send X-Api-Version: 2026-04-14 on every request. See the Quickstart for the full credential setup.
The rest of this guide assumes both are exported. Each curl below repeats the two credential headers and the version header.
2

Create and verify a user

Create the user with POST /v1/users. Send the product-required compliance fields at creation — including expected_monthly_volume and expected_transaction_count — so they are part of the initial verification. Supplying them later, after the user is VERIFIED, re-triggers verification and can temporarily block virtual-account creation (see Known limitations).
This minimal body returns 201, but a user only verifies once it is complete for a product — send the full field set for its category (documents, address, questionnaire) as described in Creating users. Then poll until the user is verified, gating on status (not verification_status, which can contradict it):
VERIFIED is necessary but not sufficient — the next step re-checks product eligibility. POST /v1/virtual-accounts evaluates the user against the US_BANK product’s required fields, which go beyond the two above: identity + documents, account_purpose, employment_status and its follow-ups (current_employer / occupation / income_source), ssn (required for US citizens), and the US-bank questions (has_us_bank_account, has_denied_bank_account). Send them all at user creation. If any are missing the create call still returns 201 and the VA is created, but it stalls in activating instead of activating — see the troubleshooting note in the next step.
Gate on status: VERIFIED. Document files must be real, retrievable bytes — verification downloads each file at submission, so placeholder or unreachable document URLs leave the user stuck in CREATED even though verification_triggered was true. A terminal REJECTED means the provider declined the data; the reason arrives only in the user.verification.failed webhook. See State machines.
3

Open a virtual account and watch it activate

Create the virtual account for the verified user with POST /v1/virtual-accounts. In the sandbox use bank: "slovak_savings_bank" (in production the same rail is bank: "portage").
The VA activates on its own. The sandbox polls provider status every 15 seconds, so within about a minute the virtual_account.activated webhook fires and the account becomes funds-ready. Confirm readiness by reading the VA until account_number is a real value — it stays null while an international / crypto VA provisions (on the ACT rail, bank: "austin_capital_trust", it shows the "PENDING-ACT-ACCOUNT" placeholder instead).
approved does not mean funds-ready — confirm with account_number and the virtual_account.* webhooks. While the VA is still activating, GET /v1/virtual-accounts/{id}/balance returns 400. If a VA stays in activating well beyond a minute, read its status_reason: a value like User is not eligible for US_BANK: Missing required fields for this product [missing: …] means the user was missing product-required fields at creation (see the previous step). The VA does not self-heal — complete those fields on the user, then create a new VA. If status_reason is empty and it still won’t activate, contact your Kira representative. See Known limitations.
4

Simulate a deposit

POST /v1/virtual-accounts/{virtual_account_id}/simulate-deposit has two modes, chosen by the request body: send { amount } (below) to simulate an inbound deposit into an active VA, or { transferUuid } to fund a crypto-funded payout (see the note at the end of this step). It is sandbox-only — the same call returns 403 "Simulate deposit is only available in sandbox environments" in production. The amount mode exercises your deposit-notification, reconciliation, and webhook handling.
  • amount — a positive USD string with up to 2 decimals (more returns 400).
  • payment_type — the inbound rail: wire (default) or ach.
  • sender_name — optional; defaults to "Simulated Sender".
A 201 returns the simulated deposit. status is lowercased and depends on the VA mode and amount:
The call also fires the virtual_account.deposit_funds_received webhook. The fees and net_amount in the response mirror what the real settlement path would charge, using your configured fee schedule — they are not fixed numbers.
A simulated deposit credits the VA balance. A fiat deposit completes at simulation time (status: completed, settlement_triggered: false — there is no async settle step to wait for) and, on the fiat wire rail, credits the virtual account’s available balance; a crypto deposit starts pending and the settlement pipeline advances it to completed. Confirm it either through the virtual_account.deposit_funds_received webhook (recommended for event-driven handling) or by the balance delta on GET /v1/virtual-accounts/{id}/balance (which may take a few seconds to reflect). It also appears in GET /v1/virtual-accounts/{id}/deposits. Because a payout debits that same balance, the deposit above and the payout below form one connected deposit → balance → payout cycle. A payout draws on the VA’s whole available balance (any opening sandbox float plus the deposits you credit), and not every rail moves its balance on a simulated deposit — so top the balance up first and keep the webhook as the rail-independent signal. See Known limitations.
Second mode — fund a crypto-funded payout with { transferUuid }. A crypto-funded payout (priced with payment_instructions — see Crypto-funded off-ramp) is created CREATED, awaiting an on-chain deposit to its per-payout wallet. Instead of { amount }, send the payout’s transfer id as { "transferUuid": "…" } to simulate that funding deposit so the payout can proceed — no real stablecoin transfer needed.
Send exactly one of amount or transferUuid — a body with both, or neither, returns 400. Note transferUuid is camelCase, while the amount-mode fields are snake_case. The 201 returns { "transfer_uuid", "status", "job_enqueued": true, "deposit_wallet": { "address", "network", "currency" } }. The transfer must be a crypto-funded payout attached to this VA and still awaiting funding (status: CREATED) with a minted deposit wallet — otherwise 404 "Transfer not found" (also returned for a foreign-client or wrong-VA transfer) or 400 ("Transfer is not a crypto-funded payout", "… missing deposit wallet", or "Transfer is not awaiting funding (status: …)"). Repeat calls are idempotent (a deterministic synthetic txHash), so re-sending won’t double-fund.
5

Create a recipient for the payout

A payout’s rail comes from the recipient, so create one with the account_type you want to pay over. A slovak_savings_bank VA supports WIRE.
The recipient response uses recipient_id (not id) and created_ts (not created_at), and the request account object comes back as a top-level account_type plus account_details. Recipients are immutable and are scoped to your client — a payout may target any recipient in your client. See Payouts, quotations, and recipients and Known limitations.
6

Price a quotation

POST /v1/quotations prices the payout. Pass virtual_account_id to price against the VA and persist a redeemable quote_id that locks the fees and FX rate; add from_held_balance: true so the quote can be redeemed by a payout that debits the VA balance.
The response returns source and recipient money objects, a depeg-aware conversion block, an itemized fees[] array, and totals; the quote expires at quote_expires_at (default 15 minutes). The itemized line-by-line breakdown is returned on the 2026-06-01 pin used above — earlier versions return a simpler preview shape. For a pre-account preview that does not persist, send quote_for: { rail, currency } instead of virtual_account_id (it returns quote_id: null). See Itemized quotations.
Quote the flow with the same rail as the recipient you will pay — a quote pins the money (amounts, currencies, network), not the rail, and redeeming it against a different rail can apply a price that no longer matches. See Payouts, quotations, and recipients.
7

Create the outbound payout

First confirm the VA’s balance can cover the source amount. The payout debits the VA’s available sandbox balance — which reflects any simulated deposits you have credited plus any opening sandbox float:
Preview to see the fee breakdown, then create the payout, redeeming the quote by passing quote_id:
If the available balance is less than the source amount, the payout is rejected synchronously with 400 "Insufficient balance. Available: … Required: …". That rejection is terminal — simulate the deposit first so the balance covers the amount; a later deposit raises the balance for the next payout but does not retroactively complete an already-rejected one. There is no fee-schedule endpoint and no dry_run; an amount below the minimum returns 400 "Total fees exceed or equal the payout amount", found only by iterating. The 201 create response returns lowercase status: "created", while GET /v1/payouts/{id} returns CREATED for the same payout — compare statuses case-insensitively. See Known limitations.
8

Watch each step complete

Two things complete in this walkthrough. You observe each through status plus webhook — and, on the fiat wire rail, the deposit is also visible as a balance delta on GET /v1/virtual-accounts/{id}/balance.The deposit completes at simulation time: a fiat deposit returns status: completed immediately, credits the VA’s available balance, and fires virtual_account.deposit_funds_received. Handle that webhook to reconcile the inbound notification.The payout advances through its state machine on its own in the sandbox. Poll GET /v1/payouts/{id} until it reaches a terminal status, or consume the webhooks:
The status walks CREATEDPENDINGPROCESSINGCOMPLETED (a KYT compliance screen can add a non-terminal KYT_PENDING; a terminal failure surfaces as FAILED). The matching webhooks are payout.created, payout.pending, payout.processing, and payout.completed (plus payout.status_changed on every transition). Completion is not guaranteed instant — treat it like VA activation and drive your flow off the webhook or a poll, not a fixed wait.
Casing differs across surfaces — compare case-insensitively. GET /v1/payouts/{id} returns UPPERCASE status; the flat payout.* webhooks carry lowercase data.status; payout.status_changed carries UPPERCASE at data.data.status. See State machines for the full vocabulary and Webhooks for the event catalog and payload shapes.

What completes, and how you observe it

Next steps

Webhooks

The event catalog and payload shapes for the user, virtual-account, deposit, and payout events used above.

State machines

The full status vocabulary and transitions, and how casing differs across surfaces and versions.

Itemized quotations

The full pricing surface of POST /v1/quotations and how to lock and redeem a quote.

Known limitations

Every sandbox and API quirk referenced here, in one place.