Core ConceptsVirtual Accounts

Virtual Accounts

A dedicated bank account per customer with a standing stablecoin destination, so deposits convert and deliver without another API call.

What a virtual account is

A virtual account is a customer's dedicated fiat receiving account plus a standing instruction. It has two halves:

  • deposit_instructions: what the sender's bank needs, in the rail's native vocabulary. For USD that is an account_number and a routing_number, plus the bank and account holder details. One account receives over every rail listed in payment_rails.
  • destination: where delivered funds go, either an external address or one of the customer's held wallets. Every deposit on the account converts and delivers there with no further calls.

Prerequisites

  • The currency's endorsement (endo_usd) is approved. An endorsement you never requested returns 400 endorsement_required, naming the exact request to make. One requested but not yet approved, whether incomplete, pending, or in issues, returns 400 endorsement_not_approved.
  • The customer has a name. It becomes account_holder_name, so a nameless customer returns 400 customer_name_required.
  • The destination's asset and chain form a live corridor for the currency, otherwise 400 unsupported_corridor. See Coverage.
  • One account per currency per customer. A second create returns 409 virtual_account_exists naming the existing account: PATCH its destination instead.
  • A destination into a held wallet needs that wallet to exist and be active: otherwise 404 wallet_not_found or 400 wallet_not_active.
  • A partner fee needs your own balance account set up, otherwise 400 no_fee_destination.
  • A partner_reference_id already used by another account in this environment returns 409 partner_reference_id_exists.

Creating one

curl -s $BASE/api/customers/cus_.../virtual_accounts \
  -H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" -d '{
    "currency": "usd",
    "destination": {
      "kind": "address",
      "chain": "solana",
      "asset": "usdc",
      "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU"
    },
    "partner_fee_bps": "100",
    "partner_reference_id": "acct-4471"
  }'
{
  "id": "va_033y2GhUPnpX0X3kBvTwRy",
  "object": "virtual_account",
  "customer_id": "cus_033y1FhTOmoW9W2jAuSvQx",
  "currency": "usd",
  "deposit_instructions": {
    "kind": "us_bank_account",
    "currency": "usd",
    "payment_rails": ["ach", "wire"],
    "account_holder_name": "Ana Silva",
    "account_holder_address": "300 Post St, San Francisco, CA 94108, US",
    "bank_name": "Example Bank NA",
    "account_number": "123456789",
    "routing_number": "021000021",
    "deposit_reference": null
  },
  "partner_fee_bps": "100",
  "partner_fee_flat": "0",
  "status": "active",
  "destination": {
    "kind": "address",
    "chain": "solana",
    "network": "mainnet",
    "asset": "usdc",
    "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
    "wallet_id": null,
    "memo": null
  },
  "partner_reference_id": "acct-4471",
  "created_at": "2026-08-26T09:20:03+00:00"
}

Notes:

  • currency is required, with no default. payment_rails is optional: omit it and the account receives over every rail the bank serves for that currency, listed in the response. If you name rails, each must serve the currency (400 unsupported_rail) and be available now (400 rail_unavailable).
  • deposit_instructions.kind names the account family (us_bank_account for USD), which tells you which native fields to read. See Coverage.
  • Display deposit_instructions to your customer verbatim. It is everything a sender needs.
  • partner_fee_bps and partner_fee_flat are your fee on this account's deposits. Both default to 0 and are never null. See Fees & Quotes.

Delivering into a held wallet

Set the destination kind to wallet and the wallet supplies the chain and the address:

{
  "currency": "usd",
  "destination": {"kind": "wallet", "asset": "usdc", "wallet_id": "wal_0K3y..."}
}

chain, address, and memo are resolved from the wallet, so sending them is an error. This is the standing route behind a dollar-account product: fiat arrives, converts, and lands in the balance your customer sees.

Repointing the destination

curl -s -X PATCH $BASE/api/virtual_accounts/va_... \
  -H "Api-Key: $KEY" -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"destination": {"kind": "address", "chain": "solana", "asset": "usdt",
                       "address": "5HSqKCN2eoHWe7p36GeUftEL9XnT6MbsvrxHgwgpYWQf"}}'

PATCH is partial: only the fields you send change. Each deposit records the destination it was paid to, so a repoint never redirects a deposit already received. We audit every destination change with its old and new value, because the payout address is the most security-sensitive field in the system.

The same PATCH reprices partner_fee_bps and partner_fee_flat. Send 0 to stop charging.

Guard this endpoint in your own systems. Whoever can repoint a destination controls where all future deposits are delivered. Use a read_only key anywhere you do not strictly need mutations.

Statuses

virtual_account.activated fires when an account goes live. For every status, see Statuses & Lifecycles.

Listing

GET /api/customers/{customer_id}/virtual_accounts lists one customer's accounts. GET /api/virtual_accounts lists all of them and filters by customer_id and partner_reference_id. Both return the same objects. For one account's deposit history, call GET /api/deposits?virtual_account_id=va_....

There is no delete. Accounts are long-lived receiving addresses: contact support to close one.