Skip to main content
Before you start: the OMS API is in early access. Every endpoint, including the ones in this guide, requires an early-access API key. Request access before you begin.Authenticate by exchanging your API key and secret for a bearer token at POST /auth/token, then send it as Authorization: Bearer {accessToken} on every request. Every POST that creates or changes a resource also requires an Idempotency-Key header. See Get started for the full flow.
This guide sets up a virtual account: a dedicated US bank account number for one customer. Each deposit is delivered to the destination you choose, converted to crypto for a wallet destination, or forwarded or held as USD for a bank or fiat wallet destination. OMS creates the transaction when the money arrives, so there is no quote step. For how virtual accounts compare with deposit addresses, see Virtual accounts.
Virtual account flow
1App→OMSCreate the virtual account with POST /virtual-accounts
2OMS→AppProvisioning completes and populates depositInstructions
3App→CustomerShare bank deposit instructions
4Customer→BankInitiate ACH or wire transfer
5OMSDetect deposit, auto-create fiatAccountToCrypto transaction
6OMS→AppWebhook: transaction.statusChanged

Prerequisites

Before you can create a virtual account, you need:
  1. A customer with a cst_ ID and the usd endorsement active.
  2. A destination: usually a registered external wallet (a walletExternal external account with an ext_ ID, from POST /external-accounts). Raw blockchain addresses are not accepted. See the destination options below for bank and fiat wallet destinations.
  3. A virtual account provider configured for your project. Contact us to enable it.
  4. A webhook subscription covering virtualAccount.statusChanged and transaction.statusChanged, so you learn when the account is ready and when each deposit is delivered. See Receiving webhooks.

Create a virtual account

Create the account with POST /virtual-accounts. You name the customer, the fiat source, and the wallet that receives the converted crypto.
  • customerId: the customer who owns the account. Required.
  • source: the fiat side the account accepts. Required. asset must be usd and network must be usBank today.
  • destination: where the inbound value is delivered. Required. walletExternal with details naming a registered external wallet account id (ext_...), plus asset and network (raw blockchain addresses are not accepted; register the wallet as an external account first). Or a registered bank account (bankUs on ach, achSameDay, or wire; bankIban; or bankCanada) to forward the USD onward. Or walletFiat with the customer’s OMS fiat wallet, which holds the inbound USD as a fiat balance credited by an internal book transfer. Or walletCrypto with an OMS wallet’s wlt_ ID, accepted only where the institution issuing the virtual account custodies wallets itself and otherwise rejected with 422 DestinationWalletCryptoNotSupported; in that case register the wallet as an external account and pass its ext_ ID. Bank destinations settle USD only. Virtual accounts do not support rtp on bank destinations.
  • type: must be bankUs. Required.
  • bankMemo: an optional memo the customer can include on the wire or ACH transfer.
  • returnDestination: optional. Where a deposit is returned if it cannot be delivered: bankUs, bankIban, or bankCanada with an id and network, or walletFiat with an id.
  • sponsorGas: when true, OMS absorbs the onchain gas cost of the destination delivery. Only true is currently supported.
  • label and metadata: an optional display label and an optional string-to-string map for your own references.
The 201 response returns the virtual account with a va_ ID:
depositInstructions is omitted on the 201 response: OMS provisions the underlying deposit account asynchronously. A crypto wallet destination has no payout; a bank or walletFiat destination reports its rail in payout, and the bank account in destination carries no rail. Wait for virtualAccount.statusChanged with data.resource.status of active, or poll GET /virtual-accounts/{virtualAccountId} until status is active and depositInstructions is present. If it moves to failed instead, read failure and create a new virtual account.

Deposit instructions

Once the account is active, display its bank deposit instructions to the customer. depositInstructions.bankUs[] lists each domestic routing option, with a bic when the account also accepts international wires. The supportedSources array tells you which rails reach the account.
Address the inbound transfer to the customer’s own name (read it from the customer resource using customer.id). On an international wire, funds route through a correspondent bank with the OMS bank partner as the beneficiary; the memo references the customer’s provisioned account so the receiving bank credits the correct account.

Test in sandbox

In sandbox, simulate an inbound fiat transfer to exercise the auto-created transaction path without moving real funds. Call POST /virtual-accounts/{virtualAccountId}/simulate/inbound-transfer with a type-discriminated body and an Idempotency-Key. Set type to bankUs (with network set to ach, wire, or rtp) for US domestic rails, or bankIban (with network set to swift) for international rails. Amounts are decimal strings in whole units, greater than 0.00 and at most 10.00. This endpoint returns 404 in production.
US domestic transfers (ach, wire, rtp) settle synchronously and return submitted. SWIFT transfers settle asynchronously with the upstream provider and return pending plus a referenceId:
Each simulate call needs a new Idempotency-Key; reusing one replays the first result. The simulated inbound funds create the same auto-created transaction that a real deposit would, so this is the way to build and verify your webhook and reconciliation handling before going live.

Minimum deposit in production

On most projects, the account provider behind your virtual accounts requires each deposit to be at least $1.00 in production. Use at least $1.00 for every test transaction.
A deposit under $1.00, for example $0.50, is still recognized. The crypto payout does not complete: the transaction stays at processing.awaitingCryptoOut. This is expected behavior, not an error. Send the test again at $1.00 or more.
To test in production:
  1. Complete the prerequisites, including a custodial wallet for the destination, then create a virtual account with the destination chain, asset, and wallet you want.
  2. Send $1.00 or more into the virtual account by Fedwire or RTP.
  3. Confirm that OMS recognizes the deposit and that the transaction moves past processing.awaitingCryptoOut to completed.settled. See Transaction lifecycle for every status.

What OMS creates when funds arrive

When OMS detects the deposit (real or simulated), it creates a transaction in processing and sends transaction.created. The bank deposit and the crypto delivery are separate transfers, each reported through transfer.created and transfer.statusChanged. The transaction’s precursor is the virtual account, and pricing is calculated when the funds arrive.
What to notice:
  • precursor is { "type": "virtualAccount", "id": "va_..." }; dereference the virtual account by id for its deposit instructions.
  • pricing.source.amount is the fiat amount actually deposited, now known. Amounts are padded to each asset’s precision (two decimals for usd, six for usdc).
  • pricing.fees lists your developer fee and the OMS fee, both charged on the source side.
  • fixedAmountSide is source: the deposited amount is fixed and the crypto delivered is calculated from it.
  • The source’s owner names the external sender (a counterparty of your customer); the destination has no owner because it is the customer’s own account.
  • The bank source carries no rail. The deposit’s rail is on its entry in transfers; fetch the transfer with GET /transfers/{transferId} for its tracking identifiers.

Track the transaction

Branch on the transaction status. processing means the deposit was detected and conversion is underway; completed means crypto was delivered to the wallet; failed is a terminal failure with a failure object (for example an ACH return). See the transaction lifecycle for the full status model and sub-statuses. Prefer webhooks over polling: OMS sends transaction.statusChanged on each transition of the auto-created transaction, with the full transaction under data.resource, so your handler branches on data.resource.status. See Webhook events for the envelope and the full catalog. If you do poll, read the transaction directly, or list a virtual account’s transactions with GET /transactions?virtualAccountId={virtualAccountId}:
Or scope a listing to the customer:

Reuse

A virtual account is persistent. While it is active it keeps monitoring its bank account number, so every subsequent transfer triggers the same flow: a new transaction with a new txn_ ID, the same virtualAccountId, and pricing computed from the same configuration. There is no limit on the number of transactions a single virtual account can produce.

Status lifecycle

Manage virtual accounts

List

GET /virtual-accounts spans every customer in your organization. Filter with customerId and status, both optional. Deleted virtual accounts are returned only under status=deleted. Paginate with limit, startingAfter, and endingBefore:
The response is a list envelope: { object, data, hasMore, nextCursor, previousCursor }. Pass nextCursor as startingAfter to fetch the next page, or previousCursor as endingBefore to page backward; hasMore signals whether more rows exist in the direction of travel.

Update

PATCH /virtual-accounts/{virtualAccountId} accepts destination, returnDestination, sponsorGas, label, and metadata. Any other key in the body is rejected with 400.
Re-pointing destination to a healthy external account recovers a virtual account from inactiveActionRequired back to active. A re-point on an already active account updates the target without a status change. The new destination must be the same kind as the old one: switching between a wallet or bank destination and walletFiat is rejected with 422 DestinationTypeSwitchNotSupported. Send returnDestination: null to clear the return destination. Some providers accept a re-point only while the account is active.

Delete

DELETE /virtual-accounts/{virtualAccountId} closes the underlying deposit account with its provider and answers 204 on a completed delete. An accepted delete moves the account to deleting and sets deletionRequestedAt. Both the move to deleting and the subsequent finalization fire virtualAccount.statusChanged, so you can poll GET /virtual-accounts/{virtualAccountId} or subscribe to the status event. The deleted virtual account stays readable by ID; depositInstructions is omitted once the account finalizes to deleted.
A delete is refused with 409 while the account is still pending (CannotDeletePendingVirtualAccount), holds a balance (NonZeroBalance), has open transactions (PendingTransactionsExist), or is already being deleted (DeleteInProgress). If the underlying provider cannot take the close right now, OMS answers 503 with Retry-After and the account returns to its prior status (active, frozen, or inactiveActionRequired); retry the DELETE later. A refused close moves the account from deleting to failed. A deposit that arrives after the delete is accepted is held and blocks the close until the funds are returned to the sender and the DELETE is retried. OMS also reconciles stuck deletes on its own: if a close that was accepted earlier never completes, it re-sends the close with the same safeguards a partner retry runs (zero balance, no open transactions).

Deposit address vs. virtual account

Both are persistent auto-route configurations. The difference is which side is fiat: