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.
Virtual account provisioning and management is not yet available in the OMS API. To be notified when it launches, register your interest.

Register interest

Share your use case and we’ll reach out when virtual account provisioning and management is available.
The flow below shows the intended pattern end to end. The create and manage steps (Step 1 and the Managing Virtual Accounts section) are not yet available: there are no endpoints to create, update, list, or delete a virtual account today. The transaction reads referenced in Step 3 (GET /transactions/{id} and GET /customers/{id}/transactions) are available now.
Virtual account flow
1AppOMSPOST /customers/{id}/virtual-accounts
2OMSApp{accountNumber: ”…”, routingNumber: ”…”, id: “va_…“}
3AppCustomerShare account and routing numbers
4CustomerBankInitiate ACH transfer
5BankOMSACH deposit arrives
6OMSAuto-create fiatToCrypto transaction
7OMSAppWebhook: transaction.fiatToCrypto.completed

Prerequisites

Before creating a Virtual Account, you need:
  1. A customer with a cst_ ID who has the usd endorsement active.
  2. A destination wallet: a custodial wallet (wlt_) to receive the converted crypto.
  3. Webhook subscriptions configured in the OMS Dashboard for the relevant transaction.fiatToCrypto.* events.

Step 1: Create the Virtual Account

Creating a virtual account is not yet available in the OMS API. The request and response below describe the intended shape so you can plan your integration.
A Virtual Account gives a customer a dedicated bank account number. When fiat arrives at that account number via the assigned rail, OMS automatically creates and executes a fiatToCrypto transaction. No amount is specified upfront: the amount is determined when the deposit lands.

Request

POST /v0.9/customers/cst_01H9Xa.../virtual-accounts
Authorization: Bearer {api_key}
Idempotency-Key: va-alice-usd-ach-001
Content-Type: application/json
{
  "source": {
    "asset": "usd",
    "network": "usBank"
  },
  "destination": {
    "wallet": {
      "wallet": "wlt_01H9Xb..."
    },
    "asset": "usdc",
    "network": "polygon"
  },
  "developerFees": [
    { "type": "percentage", "rate": "0.010", "wallet": "wlt_01H9Xf..." }
  ],
  "label": "Alice USD virtual account",
  "accountholderName": "oms",
  "metadata": {
    "purpose": "customer_funding"
  }
}
Required fields:
  • source.asset: usd (the only supported fiat).
  • source.network: usBank (the only supported rail). The specific routing details are assigned by OMS and surfaced in the response.
  • destination.wallet: A WalletTarget object with one of wallet, blockchainAddress, or externalAccount (cpBcAddr_ prefix).
  • destination.asset: Target crypto asset (usdc or usdt).
  • destination.network: Target rail (polygon or ethereum).
Optional fields:
  • developerFees: Percentage-only because the deposit amount is unknown until arrival. Each entry takes an optional wallet directing that fee to a specific OMS wallet.
  • accountholderName: Whose name appears on the bank account: customer, oms (default), or developer.
  • label, bankMemo, metadata (up to 20 keys).

Response: 201 Created

{
  "id": "va_01H9Xv...",
  "object": "virtualAccount",
  "customerId": "cst_01H9Xa...",
  "status": "active",
  "source": {
    "asset": "usd",
    "network": "usBank"
  },
  "bankDetails": {
    "bankName": "OMS Trust Bank",
    "accountNumber": "8675309123",
    "routingNumber": "021000021",
    "accountType": "checking",
    "ownerName": "Polygon OMS Payments Inc.",
    "reference": "VA-01H9Xv-ABCDEF"
  },
  "destination": {
    "wallet": {
      "wallet": "wlt_01H9Xb..."
    },
    "asset": "usdc",
    "network": "polygon"
  },
  "developerFees": [
    { "type": "percentage", "rate": "0.010", "wallet": "wlt_01H9Xf..." }
  ],
  "omsFeeSchedule": {
    "feeCurrency": "usd",
    "entries": [
      { "type": "percentage", "rate": "0.015" },
      { "type": "fixed", "amount": "1.00" }
    ]
  },
  "label": "Alice USD virtual account",
  "bankMemo": null,
  "accountholderName": "oms",
  "metadata": {
    "purpose": "customer_funding"
  },
  "createdAt": "2024-01-15T10:00:00Z",
  "updatedAt": "2024-01-15T10:00:00Z"
}
Key fields in the response:
  • id: The Virtual Account ID (prefix va_). Use this for GET, PATCH, and DELETE operations.
  • status: Starts as active. Can be changed to paused or deleted later.
  • source: Echoes the requested asset (usd) and rail (usBank).
  • bankDetails: Top-level field with the assigned bank account number, routing number, owner name, and a unique reference string. Server-generated and unique to this Virtual Account. Surface these to your customer as the deposit destination.
  • accountholderName: Whose name appears on the bank account (customer, oms, or developer). Defaults to oms.
  • omsFeeSchedule: Shows how OMS fees will be calculated on auto-created transactions. Since there is no Quote step for Virtual Accounts, this gives you visibility into the fee structure upfront.
What to do with the response: Surface bankDetails to your customer as their dedicated bank account details. Store va_01H9Xv... in your system to link incoming transactions back to this account.

Test in sandbox

Once a virtual account is provisioned for you, you can simulate an inbound fiat transfer in sandbox instead of sending real funds. Call POST /virtual-accounts/{virtualAccountId}/simulate with a rail (ach_in, wire_in, or swift_in) and an amount in cents. OMS fires the same transaction.fiatToCrypto.* webhooks as a real deposit. This endpoint returns 404 in production.
curl -X POST https://sandbox-api.polygon.technology/v0.9/virtual-accounts/va_01H9Xv.../simulate \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{ "rail": "ach_in", "amount": { "currency": "USD", "value": "50000" } }'

Step 2: Customer Sends Fiat

The customer initiates an ACH or wire transfer from their bank to the account and routing number in bankDetails. There is nothing to do on the API side at this point: OMS monitors the assigned bank account for incoming fiat. For this example, the customer sends 500.00 USD via ACH to account 8675309123.

Step 3: OMS Detects the Deposit and Auto-Creates a Transaction

When OMS receives the fiat deposit, it automatically creates a Transaction in processing status. This transaction skips the Quote step entirely: there is no Quote object, and quote is null.

Webhook: transaction.fiatToCrypto.processing

OMS fires this webhook as soon as the transaction is created:
{
  "id": "whk_evt_01H9Xw...",
  "event": "transaction.fiatToCrypto.processing",
  "createdAt": "2024-01-15T14:30:00Z",
  "data": {
    "id": "txn_01H9Xd...",
    "object": "transaction",
    "type": "fiatToCrypto",
    "status": "processing",
    "subStatus": "processing.fundsDetected",
    "customerId": "cst_01H9Xa...",
    "quoteId": null,
    "depositAddress": null,
    "virtualAccount": "va_01H9Xv...",
    "cashIn": null,
    "source": {
      "asset": "usd",
      "network": "usBank",
      "amountGross": "500.00",
      "amountNet": "494.00",
      "feesDeducted": {
        "total": "6.00",
        "developer": "5.00",
        "oms": "1.00",
        "gas": "0.00"
      },
      "txHash": null
    },
    "destination": {
      "wallet": {
        "wallet": "wlt_01H9Xb..."
      },
      "asset": "usdc",
      "network": "polygon",
      "amountGross": "494.00",
      "amountNet": "494.00",
      "feesDeducted": {
        "oms": "0.00",
        "gas": "0.00"
      }
    },
    "fixedAmountSide": "source",
    "sponsorGas": false,
    "sponsorGasCost": "0.00",
    "rates": {
      "pair": "USD/USDC",
      "exchangeRate": "1.0000",
      "effectiveRate": "0.9880"
    },
    "estimatedArrival": null,
    "error": null,
    "metadata": {
      "purpose": "customer_funding"
    },
    "createdAt": "2024-01-15T14:30:00Z",
    "updatedAt": "2024-01-15T14:30:00Z"
  }
}
What to notice:
  • quote: null: Auto-created transactions skip the Quote step.
  • virtualAccount: "va_01H9Xv...": Links this transaction back to the originating Virtual Account.
  • source.amountGross: "500.00": The actual fiat amount deposited, now known.
  • source.amountNet: "494.00": After deducting the 5.00 developer fee and 1.00 OMS fee (from the omsFeeSchedule).
  • source.feesDeducted.developer: "5.00": Your 1.0% fee on the 500.00 USD deposit.
  • The metadata from the Virtual Account is carried over to the transaction.

Polling Alternative

If you prefer polling over webhooks, you can retrieve the transaction directly:
GET /v0.9/transactions/txn_01H9Xd...
Authorization: Bearer {api_key}
Or list all transactions for the customer:
GET /v0.9/customers/cst_01H9Xa.../transactions
Authorization: Bearer {api_key}

Step 4: Transaction Completes

OMS converts the fiat to USDC and delivers it to the destination wallet on Polygon.

Webhook: transaction.fiatToCrypto.completed

{
  "id": "whk_evt_01H9Xw2...",
  "event": "transaction.fiatToCrypto.completed",
  "createdAt": "2024-01-15T14:32:00Z",
  "data": {
    "id": "txn_01H9Xd...",
    "object": "transaction",
    "type": "fiatToCrypto",
    "status": "completed",
    "subStatus": null,
    "customerId": "cst_01H9Xa...",
    "quoteId": null,
    "depositAddress": null,
    "virtualAccount": "va_01H9Xv...",
    "cashIn": null,
    "source": {
      "asset": "usd",
      "network": "usBank",
      "amountGross": "500.00",
      "amountNet": "494.00",
      "feesDeducted": {
        "total": "6.00",
        "developer": "5.00",
        "oms": "1.00",
        "gas": "0.00"
      },
      "txHash": null
    },
    "destination": {
      "wallet": {
        "wallet": "wlt_01H9Xb..."
      },
      "asset": "usdc",
      "network": "polygon",
      "amountGross": "494.00",
      "amountNet": "494.00",
      "feesDeducted": {
        "oms": "0.00",
        "gas": "0.00"
      },
      "txHash": "0x9b4c8d...e5f6a7"
    },
    "fixedAmountSide": "source",
    "sponsorGas": false,
    "sponsorGasCost": "0.00",
    "rates": {
      "pair": "USD/USDC",
      "exchangeRate": "1.0000",
      "effectiveRate": "0.9880"
    },
    "estimatedArrival": null,
    "error": null,
    "metadata": {
      "purpose": "customer_funding"
    },
    "createdAt": "2024-01-15T14:30:00Z",
    "updatedAt": "2024-01-15T14:32:00Z"
  }
}
What changed from the processing webhook:
  • status is now completed and subStatus is null (sub-statuses only apply to the cash pickup flow).
  • destination.txHash: The onchain transaction hash confirming USDC delivery to the wallet.
  • updatedAt reflects the completion time.
At this point the flow is done. The customer’s 500.00 USD has been received via ACH, your 5.00 USD developer fee has been collected to wlt_01H9Xf..., and 494.00 USDC has been delivered to wlt_01H9Xb....

Step 5 (Reuse): More Deposits

The Virtual Account remains active and continues monitoring accountNumber: 8675309123. Every subsequent ACH transfer to that account number triggers the same flow: a new auto-created transaction with a new txn_ ID, the same virtualAccount, and fees calculated from the same configuration. There is no limit on the number of transactions a single Virtual Account can produce.

Managing Virtual Accounts

The management operations below (pause, resume, change destination, delete, and list) are not yet available in the OMS API. They describe the intended pattern.

Pause a Virtual Account

Pausing stops OMS from processing new deposits. Any in-flight transactions continue to completion.
PATCH /v0.9/virtual-accounts/va_01H9Xv...
Authorization: Bearer {api_key}
Content-Type: application/json
{
  "status": "paused"
}
Response returns the updated Virtual Account with "status": "paused".

Resume a Virtual Account

{
  "status": "active"
}

Change the Destination

You can redirect future deposits to a different wallet without creating a new Virtual Account. The source (and its bank account number) remain the same.
PATCH /v0.9/virtual-accounts/va_01H9Xv...
Authorization: Bearer {api_key}
Content-Type: application/json
{
  "destination": {
    "wallet": {
      "wallet": "wlt_01H9Xc..."
    }
  }
}
Note: You cannot change the source. If you need a different fiat currency or rail, create a new Virtual Account.

Delete a Virtual Account

Soft-deletes the account. OMS stops processing new deposits.
DELETE /v0.9/virtual-accounts/va_01H9Xv...
Authorization: Bearer {api_key}
Response: 204 No Content.

List Virtual Accounts

GET /v0.9/customers/cst_01H9Xa.../virtual-accounts?status=active
Authorization: Bearer {api_key}
{
  "data": [
    {
      "id": "va_01H9Xv...",
      "object": "virtualAccount",
      "status": "active",
      "source": {
        "asset": "usd",
        "network": "usBank"
      },
      "bankDetails": {
        "bankName": "OMS Trust Bank",
        "accountNumber": "8675309123",
        "routingNumber": "021000021",
        "accountType": "checking",
        "ownerName": "Polygon OMS Payments Inc.",
        "reference": "VA-01H9Xv-ABCDEF"
      },
      "accountholderName": "oms",
      "...": "..."
    }
  ],
  "hasMore": false,
  "cursor": null
}

Failure Handling

If a transaction fails after the deposit is detected, OMS fires a transaction.fiatToCrypto.failed webhook with an error object:
{
  "id": "whk_evt_01H9Xw3...",
  "event": "transaction.fiatToCrypto.failed",
  "createdAt": "2024-01-15T14:31:00Z",
  "data": {
    "id": "txn_01H9Xd...",
    "type": "fiatToCrypto",
    "status": "failed",
    "subStatus": "failed.bankRejected",
    "error": {
      "code": "bankRejected",
      "message": "ACH return received: insufficient funds"
    },
    "...rest of transaction object..."
  }
}
If OMS returns the fiat to the sender, you’ll receive a transaction.fiatToCrypto.refundCompleted webhook. If the refund itself fails, you’ll receive transaction.fiatToCrypto.refundFailed.

Compliance Review

In some cases a transaction may be held for compliance review. This fires a developer-only webhook (not forwarded to customers):
{
  "id": "whk_evt_01H9Xw4...",
  "event": "transaction.fiatToCrypto.underReview",
  "createdAt": "2024-01-15T14:30:10Z",
  "data": {
    "id": "txn_01H9Xd...",
    "type": "fiatToCrypto",
    "status": "processing",
    "subStatus": "processing.underReview",
    "...rest of transaction object..."
  }
}
The transaction remains in processing status during review. Once resolved, it proceeds to completed or failed with the corresponding webhook.

Summary: Webhook Events for Virtual Account Transactions

EventWhen
transaction.fiatToCrypto.processingFiat deposit detected, transaction created
transaction.fiatToCrypto.completedCrypto delivered to destination wallet
transaction.fiatToCrypto.failedDelivery failed
transaction.fiatToCrypto.refundCompletedFiat refund sent back to sender
transaction.fiatToCrypto.refundFailedRefund attempt failed
transaction.fiatToCrypto.underReviewUnder compliance review (developer-only)

Key Differences from Deposit Addresses

Virtual Accounts and Deposit Addresses are both persistent, auto-triggering configurations, but they serve different source types:
Deposit AddressesVirtual Accounts
SourceCrypto (on-chain)Fiat (usBank rail)
Deposit detailsOn-chain address (in source.depositInstructions)Dedicated bank account (top-level bankDetails)
Transaction typecryptoToCryptofiatToCrypto
ID prefixda_va_
Developer feesPercentage onlyPercentage only
Quote stepNone: auto-createdNone: auto-created
Use Virtual Accounts when your customers need a persistent bank account number they can wire or ACH transfer to repeatedly. Use Deposit Addresses when your customers are sending crypto from external wallets.