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 {token} on every request. Every mutating request (POST and PATCH) also requires an Idempotency-Key header. See Get started for the full flow.
A virtual account gives a customer a dedicated bank account number. When fiat arrives via a supported rail, OMS automatically creates and executes a fiatAccountToCrypto transaction and delivers crypto to the configured destination wallet. There is no quote step and no amount specified upfront: the amount is whatever the sender deposits.
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 for the converted crypto: a registered external wallet (a walletExternal external account with an ext_ ID, registered with POST /external-accounts). Raw blockchain addresses are not accepted.
  3. Virtual accounts enabled for your project.
  4. A webhook subscription covering transaction.statusChanged (and, optionally, the virtualAccount.* events) so you learn when the auto-created transaction is delivered. Register one with POST /webhooks (body { url, subscriptions }) or in the OMS Dashboard. See the transaction lifecycle for the delivery model.

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. A walletCrypto destination is accepted only where the institution issuing the virtual account custodies wallets itself, and is otherwise rejected with 422 DestinationWalletCryptoNotSupported; register the OMS wallet as an external account and pass its ext_ ID instead.
  • accountHolder: must be customer. Required.
  • type: must be bankUs. Required.
  • bankMemo: an optional memo the customer can include on the wire or ACH transfer.
  • sponsorGas: when true, OMS absorbs the on-chain gas cost of the destination delivery. Optional, defaults to true; 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. Subscribe to the virtualAccount.statusChanged event (one event per status transition; branch on the resource’s status), or poll GET /virtual-accounts/{virtualAccountId} until status is active and depositInstructions is populated. A virtualAccount.created event fires in the same transaction that returns the 201, so a subscriber that caches by va_ ID can seed the account from the event.

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. Set type to bankUs (with network set to ach or wire) 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) settle synchronously and return submitted. SWIFT transfers settle asynchronously with the upstream provider and return pending plus a referenceId:
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.

What OMS creates when funds arrive

When OMS detects the deposit (real or simulated), it creates a transaction in processing status and fires transaction.statusChanged. The inbound bank leg also emits virtualAccount.deposit.* events (pending, settled, failed, returned) as it progresses, and the crypto leg emits virtualAccount.cryptoTransfer.* events. The transaction skips the quote step, so its precursor is typed virtualAccount and carries the virtualAccountId. Pricing is calculated at the moment funds arrive and lives in the top-level pricing object. The direction is fiatAccountToCrypto.
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 fires transaction.statusChanged on each transition of the auto-created transaction, and each delivery carries the full transaction under data, so your handler branches on data.status. See Webhook events for the envelope and the full catalog. If you do poll, read the transaction directly:
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.

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