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.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.
Prerequisites
Before you can create a virtual account, you need:- A customer with a
cst_ID and theusdendorsement active. - A destination for the converted crypto: a registered external wallet (a
walletExternalexternal account with anext_ID, registered withPOST /external-accounts). Raw blockchain addresses are not accepted. - Virtual accounts enabled for your project.
- A webhook subscription covering
transaction.statusChanged(and, optionally, thevirtualAccount.*events) so you learn when the auto-created transaction is delivered. Register one withPOST /webhooks(body{ url, subscriptions }) or in the OMS Dashboard. See the transaction lifecycle for the delivery model.
Create a virtual account
Create the account withPOST /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.assetmust beusdandnetworkmust beusBanktoday.destination: where the inbound value is delivered. Required.walletExternalwithdetailsnaming a registered external wallet accountid(ext_...), plusassetandnetwork(raw blockchain addresses are not accepted; register the wallet as an external account first). Or a registered bank account (bankUsonach,achSameDay, orwire;bankIban; orbankCanada) to forward the USD onward. OrwalletFiatwith the customer’s OMS fiat wallet, which holds the inbound USD as a fiat balance credited by an internal book transfer. AwalletCryptodestination is accepted only where the institution issuing the virtual account custodies wallets itself, and is otherwise rejected with422 DestinationWalletCryptoNotSupported; register the OMS wallet as an external account and pass itsext_ID instead.accountHolder: must becustomer. Required.type: must bebankUs. Required.bankMemo: an optional memo the customer can include on the wire or ACH transfer.sponsorGas: whentrue, OMS absorbs the on-chain gas cost of the destination delivery. Optional, defaults totrue; onlytrueis currently supported.labelandmetadata: an optional display label and an optional string-to-string map for your own references.
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 isactive, 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.
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. CallPOST /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.
ach, wire) settle synchronously and return submitted. SWIFT transfers settle asynchronously with the upstream provider and return pending plus a referenceId:
What OMS creates when funds arrive
When OMS detects the deposit (real or simulated), it creates a transaction inprocessing 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.
precursoris{ "type": "virtualAccount", "id": "va_..." }; dereference the virtual account byidfor its deposit instructions.pricing.source.amountis the fiat amount actually deposited, now known. Amounts are padded to each asset’s precision (two decimals forusd, six forusdc).pricing.feeslists your developer fee and the OMS fee, both charged on the source side.fixedAmountSideissource: the deposited amount is fixed and the crypto delivered is calculated from it.- The source’s
ownernames the external sender (a counterparty of your customer); the destination has noownerbecause it is the customer’s own account. - The bank
sourcecarries no rail. The deposit’s rail is on its entry intransfers; fetch the transfer withGET /transfers/{transferId}for itstrackingidentifiers.
Track the transaction
Branch on the transactionstatus. 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:
Reuse
A virtual account is persistent. While it isactive 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:
{ 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.
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.
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:Related
- Virtual accounts overview: concept summary and comparison with deposit addresses
- Deposit addresses guide: the crypto equivalent for on-chain deposits
- Transaction lifecycle: statuses, sub-statuses, and webhook events for the auto-created transaction