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.
Virtual accounts must be enabled for your project before POST /virtual-accounts succeeds. Contact us to enable virtual accounts for your project.

Contact us

Share your on-ramp use case and we’ll enable virtual accounts for your project.
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
1AppOMSCreate the virtual account with POST /virtual-accounts
2OMSAppProvisioning completes and populates bankDetails
3AppCustomerShare bank deposit instructions
4CustomerBankInitiate ACH or wire transfer
5OMSDetect deposit, auto-create fiatAccountToCrypto transaction
6OMSAppWebhook: transaction.fiatToCrypto.completed

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: the customer’s OMS wallet (a wlt_ ID, created with POST /customers/{customerId}/wallets) or a registered external wallet (an ext_wlt_ ID, registered with POST /external-accounts). Raw blockchain addresses are not accepted.
  3. Virtual accounts enabled for your project (contact us).
  4. A webhook subscription covering the transaction.fiatToCrypto.* events (and, optionally, the virtualAccount.* events) so you learn when the auto-created transaction is delivered. Register one with POST /webhooks (body { url, events }) 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.
curl -X POST https://sandbox-api.polygon.technology/v0.10/virtual-accounts \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: va-alice-usd-001" \
  -d '{
    "customerId": "cst_01H9Xa...",
    "source": { "asset": "usd", "network": "usBank" },
    "destination": {
      "type": "walletOms",
      "details": {
        "id": "wlt_01H9Xb...",
        "asset": "usdc",
        "network": "polygon"
      }
    },
    "accountHolder": "customer",
    "type": "bankUs",
    "bankMemo": "Alice funding",
    "label": "Alice USD funding account"
  }'
curl -X POST https://api.polygon.technology/v0.10/virtual-accounts \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: va-alice-usd-001" \
  -d '{
    "customerId": "cst_01H9Xa...",
    "source": { "asset": "usd", "network": "usBank" },
    "destination": {
      "type": "walletOms",
      "details": {
        "id": "wlt_01H9Xb...",
        "asset": "usdc",
        "network": "polygon"
      }
    },
    "accountHolder": "customer",
    "type": "bankUs",
    "bankMemo": "Alice funding",
    "label": "Alice USD funding account"
  }'
  • 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 converted crypto is delivered. Required. Either walletOms (details with the customer’s OMS wallet id, asset, and network) or walletExternal (details with a registered external wallet account id such as ext_wlt_..., plus asset and network). Raw blockchain addresses are not accepted; register the wallet as an external account first. OMS validates asset and network against the resolved account.
  • 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:
{
  "id": "va_01H9Xv...",
  "object": "virtualAccount",
  "customerId": "cst_01H9Xa...",
  "status": "pending",
  "statusReason": null,
  "failureReason": null,
  "source": { "asset": "usd", "network": "usBank" },
  "destination": {
    "type": "walletOms",
    "category": "crypto",
    "details": {
      "id": "wlt_01H9Xb...",
      "asset": "usdc",
      "network": "polygon"
    }
  },
  "bankDetails": null,
  "bankMemo": "Alice funding",
  "label": "Alice USD funding account",
  "metadata": {},
  "deletionRequestedAt": null,
  "deletionRequestedBy": null,
  "finalBalance": null,
  "createdAt": "2026-01-15T14:30:00Z",
  "updatedAt": "2026-01-15T14:30:00Z"
}
bankDetails is null in the 201 response: OMS provisions the underlying deposit account asynchronously. Subscribe to the virtualAccount.provisioned event (fired when bankDetails is populated) and virtualAccount.active (fired when the account is accepting deposits), or poll GET /virtual-accounts/{virtualAccountId} until status is active and bankDetails is populated.

Deposit instructions

Once the account is active, display its bank deposit instructions to the customer. bankDetails carries dual-rail instructions: a domestic block for funding from a US bank and a swift block for funding from an international bank.
{
  "domestic": {
    "bankName": "OMS bank partner",
    "bankAddress": "...",
    "accountNumber": "8675309123",
    "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.

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 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). This endpoint returns 404 in production.
curl -X POST https://sandbox-api.polygon.technology/v0.10/virtual-accounts/va_01H9Xv.../simulate \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{ "rail": "ach_in", "amount": { "currency": "USD", "value": "50000" } }'
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_01H9Xv...",
  "amount": { "currency": "USD", "value": "50000" },
  "status": "submitted",
  "submittedAt": "2026-01-15T10:33:00Z"
}
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 the transaction.fiatToCrypto.processing event, with the full transaction under the envelope’s payload. The inbound bank leg also emits virtualAccount.deposit.* events (pending, settled, failed, returned) as it progresses. 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.
{
  "id": "txn_01H9Xd...",
  "object": "transaction",
  "status": "processing",
  "subStatus": "processing.fundsPulled",
  "customerId": "cst_01H9Xa...",
  "sourceToDestination": "fiatAccountToCrypto",
  "precursor": {
    "type": "virtualAccount",
    "details": {
      "virtualAccountId": "va_01H9Xv...",
      "depositInstructions": { "...": "..." }
    }
  },
  "source": {
    "type": "bankUs",
    "category": "fiatAccount",
    "details": {
      "asset": "usd",
      "network": "ach"
    }
  },
  "destination": {
    "type": "walletOms",
    "category": "crypto",
    "details": {
      "id": "wlt_01H9Xb...",
      "asset": "usdc",
      "network": "polygon"
    }
  },
  "pricing": {
    "source": {
      "asset": "usd",
      "amountGross": "500.00",
      "amountNet": "494.00",
      "feesDeducted": { "total": "6.00", "developer": "5.00", "oms": "1.00", "gas": "0.00" }
    },
    "destination": {
      "asset": "usdc",
      "amountGross": "494.00",
      "amountNet": "494.00"
    },
    "pair": "usd/usdc",
    "exchangeRate": "1.0",
    "effectiveRate": "0.988",
    "fixedAmountSide": "source",
    "sponsorGas": true,
    "sponsorGasCost": "0"
  },
  "estimatedArrival": null,
  "error": null,
  "createdAt": "2026-01-15T14:30:00Z",
  "updatedAt": "2026-01-15T14:30:00Z"
}
What to notice:
  • precursor.type is virtualAccount, and precursor.details.virtualAccountId links the transaction back to the originating virtual account.
  • pricing.source.amountGross is the fiat amount actually deposited, now known.
  • pricing.source.feesDeducted breaks out your developer fee and the OMS fee, both deducted from the source.
  • fixedAmountSide is source: the deposited amount is fixed and the crypto delivered is calculated from it.

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 an error 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.fiatToCrypto.processing, transaction.fiatToCrypto.completed, and transaction.fiatToCrypto.failed for the auto-created transaction, and each delivery carries the full transaction object under payload, so your handler branches on the event name or payload.status. See Webhook events for the envelope and the full catalog. If you do poll, read the transaction directly:
GET /v0.10/transactions/txn_01H9Xd...
Authorization: Bearer {token}
Or scope a listing to the customer:
GET /v0.10/transactions?customerId=cst_01H9Xa...
Authorization: Bearer {token}

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

StatusMeaning
pendingCreated; OMS is provisioning the underlying deposit account. bankDetails is still null.
activeThe account number is live. Deposits convert and deliver to the destination.
frozenDeposits are suspended. statusReason explains why.
closedThe account is permanently closed.
deletedThe asynchronous delete finished and the underlying deposit account is closed. finalBalance snapshots the balance at the moment of deletion.
failedProvisioning failed; failureReason identifies the category. Create a new virtual account.
inactiveActionRequiredThe destination external account is no longer usable. Re-point destination with PATCH to recover to active.

Manage virtual accounts

List

GET /virtual-accounts spans every customer in your organization. Filter with customerId and status, both optional. Paginate with limit, startingAfter, and endingBefore:
GET /v0.10/virtual-accounts?customerId=cst_01H9Xa...&status=active&limit=20
Authorization: Bearer {token}
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, sponsorGas, label, and metadata. Any other key in the body is rejected with 400.
curl -X PATCH https://sandbox-api.polygon.technology/v0.10/virtual-accounts/va_01H9Xv... \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: va-repoint-001" \
  -d '{
    "destination": {
      "type": "walletExternal",
      "details": {
        "id": "ext_wlt_01H9Xh...",
        "asset": "usdc",
        "network": "polygon"
      }
    }
  }'
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} returns 202: deletion is asynchronous. OMS initiates the close of the underlying deposit account, sets deletionRequestedAt (and records the caller in deletionRequestedBy), and leaves status as-is during the delete-pending window. Once the underlying account closes, status finalizes to deleted and finalBalance records the balance at that moment.
DELETE /v0.10/virtual-accounts/va_01H9Xv...
Authorization: Bearer {token}
The 202 response is the virtual account with deletionRequestedAt set. Poll GET /virtual-accounts/{virtualAccountId} to observe the transition to deleted.

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
DestinationRegistered bank external accountOMS wallet or registered external wallet