> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polygon.technology/llms.txt
> Use this file to discover all available pages before exploring further.

# Virtual accounts

> How to give a customer a dedicated bank account number that auto-converts incoming fiat deposits to crypto.

<Note>
  **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](https://info.polygon.technology/get-early-access?utm_source=docs\&utm_medium=card\&utm_campaign=oms_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](/payments/get-started) for the full flow.
</Note>

<Note>
  Virtual accounts must be enabled for your project before `POST /virtual-accounts` succeeds. Contact us to enable virtual accounts for your project.

  <Card title="Contact us" icon="envelope" href="https://info.polygon.technology/get-early-access?utm_source=docs&utm_medium=card&utm_campaign=oms_access">
    Share your on-ramp use case and we'll enable virtual accounts for your project.
  </Card>
</Note>

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.

<div style={{border:"1px solid #C8CFE1",borderRadius:"12px",overflow:"hidden",marginBottom:"24px"}}>
  <div style={{background:"linear-gradient(180deg,#EAE4F5 0%,#F6F3FB 100%)",borderBottom:"1px solid #D5C4F2",padding:"10px 16px",fontSize:"11px",fontWeight:"700",color:"#670DE5",letterSpacing:"0.06em",textTransform:"uppercase"}}>Virtual account flow</div>

  <div style={{borderBottom:"1px solid #EEF0F9",padding:"9px 16px",display:"flex",alignItems:"center",gap:"10px"}}>
    <span style={{color:"#929EBA",fontSize:"11px",fontWeight:"700",minWidth:"16px",textAlign:"right"}}>1</span>
    <span style={{background:"#EEF0F9",color:"#48526F",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"600",whiteSpace:"nowrap"}}>App</span>
    <span style={{color:"#670DE5",fontWeight:"700"}}>→</span>
    <span style={{background:"#EAE4F5",color:"#670DE5",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"700",whiteSpace:"nowrap"}}>OMS</span>
    <span style={{fontSize:"13px",color:"#141635"}}>Create the virtual account with POST /virtual-accounts</span>
  </div>

  <div style={{borderBottom:"1px solid #EEF0F9",padding:"9px 16px",display:"flex",alignItems:"center",gap:"10px"}}>
    <span style={{color:"#929EBA",fontSize:"11px",fontWeight:"700",minWidth:"16px",textAlign:"right"}}>2</span>
    <span style={{background:"#EAE4F5",color:"#670DE5",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"700",whiteSpace:"nowrap"}}>OMS</span>
    <span style={{color:"#670DE5",fontWeight:"700"}}>→</span>
    <span style={{background:"#EEF0F9",color:"#48526F",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"600",whiteSpace:"nowrap"}}>App</span>
    <span style={{fontSize:"13px",color:"#141635"}}>Provisioning completes and populates bankDetails</span>
  </div>

  <div style={{borderBottom:"1px solid #EEF0F9",padding:"9px 16px",display:"flex",alignItems:"center",gap:"10px"}}>
    <span style={{color:"#929EBA",fontSize:"11px",fontWeight:"700",minWidth:"16px",textAlign:"right"}}>3</span>
    <span style={{background:"#EEF0F9",color:"#48526F",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"600",whiteSpace:"nowrap"}}>App</span>
    <span style={{color:"#670DE5",fontWeight:"700"}}>→</span>
    <span style={{background:"#EEF0F9",color:"#48526F",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"600",whiteSpace:"nowrap"}}>Customer</span>
    <span style={{fontSize:"13px",color:"#141635"}}>Share bank deposit instructions</span>
  </div>

  <div style={{borderBottom:"1px solid #EEF0F9",padding:"9px 16px",display:"flex",alignItems:"center",gap:"10px"}}>
    <span style={{color:"#929EBA",fontSize:"11px",fontWeight:"700",minWidth:"16px",textAlign:"right"}}>4</span>
    <span style={{background:"#EEF0F9",color:"#48526F",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"600",whiteSpace:"nowrap"}}>Customer</span>
    <span style={{color:"#670DE5",fontWeight:"700"}}>→</span>
    <span style={{background:"#EEF0F9",color:"#48526F",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"600",whiteSpace:"nowrap"}}>Bank</span>
    <span style={{fontSize:"13px",color:"#141635"}}>Initiate ACH or wire transfer</span>
  </div>

  <div style={{borderBottom:"1px solid #EEF0F9",padding:"9px 16px",display:"flex",alignItems:"center",gap:"10px"}}>
    <span style={{color:"#929EBA",fontSize:"11px",fontWeight:"700",minWidth:"16px",textAlign:"right"}}>5</span>
    <span style={{background:"#EAE4F5",color:"#670DE5",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"700",whiteSpace:"nowrap"}}>OMS</span>
    <span style={{fontSize:"13px",color:"#141635",marginLeft:"4px"}}>Detect deposit, auto-create fiatAccountToCrypto transaction</span>
  </div>

  <div style={{padding:"9px 16px",display:"flex",alignItems:"center",gap:"10px"}}>
    <span style={{color:"#929EBA",fontSize:"11px",fontWeight:"700",minWidth:"16px",textAlign:"right"}}>6</span>
    <span style={{background:"#EAE4F5",color:"#670DE5",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"700",whiteSpace:"nowrap"}}>OMS</span>
    <span style={{color:"#670DE5",fontWeight:"700"}}>→</span>
    <span style={{background:"#EEF0F9",color:"#48526F",padding:"2px 8px",borderRadius:"4px",fontSize:"11px",fontWeight:"600",whiteSpace:"nowrap"}}>App</span>
    <span style={{fontFamily:"'Geist Mono',ui-monospace,monospace",fontSize:"12px",color:"#141635"}}>Webhook: transaction.fiatToCrypto.completed</span>
  </div>
</div>

## 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](/payments/core-concepts/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.

<CodeGroup>
  ```bash Sandbox theme={null}
  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"
    }'
  ```

  ```bash Production theme={null}
  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"
    }'
  ```
</CodeGroup>

* `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:

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```bash theme={null}
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:

```json theme={null}
{
  "rail": "ach_in",
  "virtualAccountId": "va_01H9Xv...",
  "amount": { "currency": "USD", "value": "50000" },
  "status": "submitted",
  "submittedAt": "2026-01-15T10:33:00Z"
}
```

<Tip>
  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.
</Tip>

## 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`.

```json theme={null}
{
  "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](/payments/core-concepts/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](/api-reference/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

| Status                   | Meaning                                                                                                                                        |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending`                | Created; OMS is provisioning the underlying deposit account. `bankDetails` is still null.                                                      |
| `active`                 | The account number is live. Deposits convert and deliver to the destination.                                                                   |
| `frozen`                 | Deposits are suspended. `statusReason` explains why.                                                                                           |
| `closed`                 | The account is permanently closed.                                                                                                             |
| `deleted`                | The asynchronous delete finished and the underlying deposit account is closed. `finalBalance` snapshots the balance at the moment of deletion. |
| `failed`                 | Provisioning failed; `failureReason` identifies the category. Create a new virtual account.                                                    |
| `inactiveActionRequired` | The 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`.

```bash theme={null}
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 address                  | Virtual account                          |
| --------------- | -------------------------------- | ---------------------------------------- |
| Incoming funds  | Crypto (on-chain)                | Fiat (bank rail)                         |
| Deposit details | On-chain inlet address           | Bank account number and routing          |
| Direction       | `cryptoToFiatAccount`            | `fiatAccountToCrypto`                    |
| Destination     | Registered bank external account | OMS wallet or registered external wallet |

## Related

* [Virtual accounts overview](/payments/virtual-accounts): concept summary and comparison with deposit addresses
* [Deposit addresses guide](/payments/guides/deposit-addresses): the crypto equivalent for on-chain deposits
* [Transaction lifecycle](/payments/core-concepts/transaction-lifecycle): statuses, sub-statuses, and webhook events for the auto-created transaction
