Skip to main content
A virtual account gives a customer a dedicated bank account number. When fiat arrives via a supported rail, OMS automatically creates a transaction (fiatAccountToCrypto) and delivers crypto to the customer’s configured wallet. No developer action is required after the account is provisioned. Virtual accounts are persistent. OMS keeps them active until they are frozen, closed, or deleted.
Virtual accounts must be enabled for your project: OMS provisions the underlying deposit accounts with a virtual account provider configured during onboarding. To get set up, share your use case below.

Contact us

Tell us about your on-ramp flow and we’ll enable virtual accounts for your project.

How it works

Once a virtual account is provisioned, you display its bank deposit instructions to the customer. When fiat arrives at that account, OMS:
  1. Detects the inbound deposit on the bank rail.
  2. Creates a transaction with a typed precursor of virtualAccount, carrying the virtualAccountId and its bank routing block.
  3. Moves the transaction directly to processing (there is no quote step, because pricing cannot be locked before the funds arrive).
  4. Converts the deposited fiat to crypto and delivers it to the configured wallet.
The resulting transaction carries sourceToDestination: "fiatAccountToCrypto" and follows the standard transaction lifecycle, including webhook events.

Creating a virtual account

Create a virtual account with POST /virtual-accounts:
{
  "customerId": "cst_...",
  "source": { "asset": "usd", "network": "usBank" },
  "destination": {
    "type": "walletOms",
    "details": { "id": "wlt_...", "asset": "usdc", "network": "polygon" }
  },
  "accountHolder": "customer",
  "type": "bankUs",
  "label": "USD funding account"
}
FieldRequiredMeaning
customerIdYesThe customer the account belongs to.
sourceYesThe inbound fiat rail. { "asset": "usd", "network": "usBank" } are the only supported values today.
destinationYesWhere converted crypto is delivered: walletOms (an OMS wallet) or walletExternal (a registered external account, referenced by its ext_wlt_ ID only; raw on-chain addresses are not accepted). OMS validates the details (asset and network) against the resolved account.
accountHolderYesMust be customer.
typeYesMust be bankUs.
bankMemoNoA wire/ACH memo the customer can include with deposits.
sponsorGasNoWhen true (the default), OMS absorbs the on-chain gas cost for the destination delivery. Only true is currently supported.
label, metadataNoA display label and free-form string metadata.
The 201 response returns the virtual account with bankDetails: null. OMS provisions the underlying deposit account asynchronously, and the account moves from pending to active once the bank details are ready.

Deposit instructions

The bankDetails field carries dual-rail bank deposit instructions to display in your UI. It is null until the underlying deposit account is provisioned. Both a domestic block and a SWIFT block are populated so a customer can fund the account from a US bank or from an international bank:
{
  "domestic": {
    "bankName": "OMS bank partner",
    "bankAddress": "...",
    "accountNumber": "123456789",
    "routingNumber": "021000021",
    "accountType": "checking",
    "network": ["ACH", "WIRE"],
    "beneficiary": { "name": "Customer name", "address": { "...": "..." } }
  },
  "swift": {
    "bankName": "Intermediary bank",
    "bankAddress": "...",
    "accountNumber": "...",
    "bic": "...",
    "memo": "FFC <customerName> <accountNumber>",
    "beneficiary": { "name": "OMS bank partner", "address": { "...": "..." } }
  }
}
On the domestic route, funds go directly to the OMS bank partner with the customer as the beneficiary. On the SWIFT route, 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.

Third-party deposits

A virtual account is a named account: it carries the onboarded customer’s own legal name, so inbound transfers are addressed to your customer, not to Polygon or your platform. Senders do not need to be registered or identity-matched to the account holder. Any third party can push funds to the account over the supported rails, and each inbound deposit auto-creates its own transaction and fires its own events, so two deposits from two different senders arrive as two records. Every inbound payment is screened (AML and sanctions) before it converts. Each deposit carries the originator details from the payment message: name, account and routing as sent, rail, amount, and reference. Wire and SWIFT payments carry the originator’s name consistently; many ACH payments do not include it, so build reconciliation on your own reference and amount rather than on sender name alone.

Listing and retrieving

GET /virtual-accounts lists virtual accounts across every customer in your organization. Both filters are optional: customerId scopes the list to one customer, and status to one lifecycle state. Paginate with limit, startingAfter, and endingBefore; each page returns nextCursor, previousCursor, and hasMore. GET /virtual-accounts/{virtualAccountId} fetches a single virtual account by ID.

Updating

PATCH /virtual-accounts/{virtualAccountId} accepts destination (re-point to a different walletOms or walletExternal target), 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 transition.

Deleting

DELETE /virtual-accounts/{virtualAccountId} returns 202 and starts an asynchronous close of the underlying deposit account. The response echoes the virtual account with deletionRequestedAt set (deletionRequestedBy identifies the caller); the status stays as-is during the delete-pending window and finalizes to deleted once the underlying account closes. finalBalance snapshots the deposit account balance at the moment the account flips to deleted.

Statuses

StatusMeaning
pendingCreated; OMS is provisioning the underlying deposit account. bankDetails is null.
activeProvisioned and accepting deposits.
frozenTemporarily suspended; inbound deposits are not processed.
closedPermanently closed.
deletedDeletion finalized; the underlying deposit account is closed.
failedProvisioning failed; failureReason identifies the cause.
inactiveActionRequiredThe destination is no longer usable. Re-point destination to a healthy account to recover to active.

Simulating inbound deposits

In sandbox, you can simulate an inbound bank deposit to test your webhook and reconciliation flows without moving real funds. This endpoint is available in non-production environments only. POST /virtual-accounts/{virtualAccountId}/simulate with a rail-discriminated body. The rail field selects the deposit type: ach_in, wire_in, or swift_in. Amounts are in minor units (cents):
{
  "rail": "ach_in",
  "amount": { "currency": "USD", "value": "5000" }
}
The SWIFT rail (swift_in) also takes the originator’s account number and BIC, which the upstream provider requires. The response echoes the simulated deposit with a submitted status:
{
  "rail": "ach_in",
  "virtualAccountId": "va_...",
  "amount": { "currency": "USD", "value": "5000" },
  "status": "submitted",
  "submittedAt": "2026-01-15T10:33:00Z"
}
Use the simulate endpoint to exercise the full auto-created transaction path in sandbox: the simulated inbound funds create a fiatAccountToCrypto transaction just as a real deposit would.

Deposit address vs. virtual account

Both are persistent auto-route configurations. The difference is which side is fiat:
Deposit addressVirtual account
Incoming fundsCrypto (on-chain)Fiat (bank rail)
Deposit detailsOn-chain inlet addressBank account number and routing
DirectioncryptoToFiatAccountfiatAccountToCrypto
DestinationFiat accountCrypto wallet