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.

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 whose precursor is { "type": "virtualAccount", "id": "va_..." }. Dereference the virtual account by that id for 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 follows the standard transaction lifecycle, including webhook events.

Creating a virtual account

Create a virtual account with POST /virtual-accounts:
The 201 response returns the virtual account in pending status with depositInstructions omitted. OMS provisions the underlying deposit account asynchronously; subscribe to virtualAccount.statusChanged to learn when the account becomes active and depositInstructions is populated.
An OMS-managed crypto wallet can back at most one active virtual account per project. A create that would share a wallet already held by another active virtual account is refused with 409 VirtualAccountWalletInUse. Delete the existing virtual account first, or point the new one at a different destination wallet.

Deposit instructions

The depositInstructions field carries the dual-rail bank deposit instructions to display in your UI. It is omitted until the provider opens the underlying deposit account, stays present while the account is deleting, and is omitted again once the account is closed, failed, or deleted. The bankUs entry lists one or more domestic routing options and, where the account accepts international wires, each entry carries a bic:
Each supportedSources entry pairs an asset with the rail that reaches this account; internationalWire is the SWIFT rail. Fields that would be empty for a given account (bic, memo, routingNumber, accountType) are omitted rather than returned as empty strings. Address the inbound transfer to the customer’s own name (read it from the customer resource using the customer.id on the virtual account). 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.

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. The auto-created transaction’s source is a bankUs or bankIban instrument with the sender’s details populated as far as the rail reports them: bankName, accountNumberLast4 (or ibanLast4), routingNumber, and the sender’s own name on details.holderName. Fields the rail does not supply are omitted rather than returned empty: wire and SWIFT payments carry the originator’s bank name and account consistently; ACH deposits typically carry only the originating company’s name. Build reconciliation on your own reference and amount rather than on sender name alone.

Destination and payout rail

The destination block is the typed instrument the deposited value is delivered to; its shape mirrors the shape used on transactions. A bank account destination carries no rail; the rail is reported next to it in payout:
payout.rail is the rail each payout moves on, the same value as the payout transfer’s rail. A bankUs destination pays out on its configured rail (ach, achSameDay, or wire); a bankIban or bankCanada destination pays out on internationalWire; and a walletFiat destination settles by bookTransfer. A walletCrypto or walletExternal destination has no payout. On ach and achSameDay, payout also carries secCode, derived from the destination owner’s and the customer’s entity type and left out while either is unknown, and companyDiscretionaryData when you set it on the destination. A configured returnDestination renders the same way: returnDestination carries the bank account instrument with no rail, and returnPayout carries only the rail used for the return.

Customer reference

Virtual accounts carry a customer reference resolving the owning customer in one hop:
The field is present on GET /virtual-accounts/{virtualAccountId}, on every entry of GET /virtual-accounts, and on the POST /virtual-accounts response. Read the customer’s name and status from the customer resource. The destination is a typed instrument with its own details block; render it from those fields rather than from a pre-formatted label. See Rendering an instrument and the Customer reference shared model.

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. Deleted virtual accounts appear only under status=deleted; the unfiltered list and every other status filter exclude them. Paginate with limit, startingAfter, and endingBefore; each page returns nextCursor, previousCursor, and hasMore.
Pagination now orders pages on (created_at, id) and emits an opaque cursor instead of a va_-prefixed ID. Pass the nextCursor or previousCursor from one page back as startingAfter or endingBefore on the next request; do not parse the cursor. A stored va_-prefixed token from a previous release is still accepted on startingAfter for one release window. endingBefore on an opaque cursor returns the adjacent newer page, not the newest n rows in the account; a client that previously walked backwards past the immediate predecessor should refresh its pagination logic.
By default, the list omits soft-deleted virtual accounts (status: deleted). Pass status=deleted explicitly to list them. GET /virtual-accounts/{virtualAccountId} fetches a single virtual account by ID, including soft-deleted rows: a finalized deleted account returns 200 so you can read the archived record. The response omits depositInstructions (the underlying deposit account is closed); every other field is populated as of the moment the account was deleted. PATCH and the simulate endpoint continue to return 404 for a deleted account.

Updating

PATCH /virtual-accounts/{virtualAccountId} accepts destination (re-point to a different walletExternal, bank, or walletFiat target of the same kind as the current destination), returnDestination, sponsorGas, label, and metadata; any other key in the body is rejected with 400. A cross-kind switch involving walletFiat is rejected with 422 DestinationTypeSwitchNotSupported. A PATCH that re-points to walletCrypto is rejected with 422 DestinationWalletCryptoNotSupported. 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. Each accepted PATCH emits a single virtualAccount.updated event with the full account under data and the set of changed announced fields in the envelope’s changedFields. A PATCH that recovers an inactiveActionRequired account fires virtualAccount.statusChanged instead; a PATCH whose body matches the current state writes no event.

Deleting

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 into a dedicated deleting status, sets deletionRequestedAt, and finalizes to deleted once the provider confirms the close. Both transitions fire virtualAccount.statusChanged, so the finalized event reads status: deleted with previousStatus: deleting. A delete can resolve three ways:
  • Closed. The provider confirms the account is closed and the virtual account finalizes to deleted.
  • Not yet available. The 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).
  • Refused. The provider refuses the close; the account moves from deleting to failed.
While a delete is in flight, depositInstructions is still present so you can keep rendering the account; it is omitted once the status finalizes to deleted. Deposits that arrive after the delete is accepted are held, and the hold blocks the close until the funds are returned and the DELETE is retried. A deleting account is not accepting deposits: deposit routing treats it as inactive, no status webhooks (freeze, unfreeze, reopen) can move it out of deleting, and only the close or an operator unwind clears the state. If a close that was accepted earlier never completes, OMS reconciles it on its own and re-sends the close with the gates a partner retry runs (zero balance, no open transactions).

Statuses

Webhook events

The lifecycle events fire for every virtual account: A PATCH that changes nothing announced writes no event. See the webhook events catalog for the delivery envelope and the per-leg events a virtual account’s inbound deposit and payout emit.

Returned payouts invalidate the destination

When a bank returns a settled ACH payout from a virtual account, OMS marks the external account the payout was sent to as invalid and moves the virtual account to inactiveActionRequired. The next inbound deposit is held until you re-point destination to a different external account, rather than being re-sent to the account that just rejected it. The virtual account fires virtualAccount.statusChanged, and the external account fires externalAccount.statusChanged to invalid. The returned funds land back on the virtual account’s own balance and can leave OMS in one of two ways:
  • Refund the sender from the OMS Dashboard, or re-send the payout re-pinned to a different external account.
  • A re-send keeps the original payout’s rail. A re-pin that would re-send to the account that returned the funds, to another account at the same underlying bank account, or to an account whose details OMS cannot read, is refused; the way out of that case is a refund.

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/inbound-transfer with a type-discriminated body. 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:
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:
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: