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

# Bank transfers

> How to move money between bank accounts and OMS wallets in both directions.

> [!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://polygon.technology/get-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 {accessToken}` on every request. Every `POST` request also requires an `Idempotency-Key` header. See [Get started](https://docs.polygon.technology/payments/get-started.md) for the full flow.

OMS moves money between bank accounts and wallets in both directions:

* **Pay out from a wallet to a bank account** (USDC to fiat): run the `POST /quotes` then `POST /transactions` pattern with the customer's OMS wallet as the source and a registered bank account as the destination.
* **Receive a bank deposit into a wallet** (fiat to USDC): provision a [virtual account](https://docs.polygon.technology/payments/guides/virtual-accounts.md). The customer sends an ACH or wire transfer to the dedicated account number, and OMS converts the deposit to crypto automatically.

A quote's `source` is an OMS wallet (`walletCrypto` for stablecoins, `walletFiat` for a USD balance) or a registered `card`. A bank account is only ever a destination: inbound bank money arrives through a virtual account, not a quote.

## Pay out from a wallet to a bank account

This flow sends USDC from a customer's wallet to a US bank account over ACH. The direction (`cryptoToFiatAccount`) follows from the source and destination types; you never set it.

### Step 1: Register the destination bank account

The quote's bank destination is an [external account](https://docs.polygon.technology/payments/external-accounts.md) referenced by its `ext_` ID. Its `type` is `bankUs` for a US bank, `bankIban` for an IBAN account, or `bankCanada` for a Canadian bank. Register it with `POST /external-accounts`. The body carries an `owner`, a `type`, and exactly one per-type object named after the type; supplying a per-type object that does not match `type` is rejected with `422`.

```bash Sandbox theme={null}
curl -X POST https://sandbox-api.polygon.technology/v0.13/external-accounts \
  -H "Authorization: Bearer {accessToken}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ext-bank-001" \
  -d '{
    "owner": { "kind": "customer", "customerId": "cst_01H9Xa..." },
    "type": "bankUs",
    "bankUs": {
      "accountNumber": "123456789012",
      "routingNumber": "021000021",
      "accountType": "checking",
      "bankName": "Example Bank"
    }
  }'
```

```bash Production theme={null}
curl -X POST https://api.polygon.technology/v0.13/external-accounts \
  -H "Authorization: Bearer {accessToken}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ext-bank-001" \
  -d '{
    "owner": { "kind": "customer", "customerId": "cst_01H9Xa..." },
    "type": "bankUs",
    "bankUs": {
      "accountNumber": "123456789012",
      "routingNumber": "021000021",
      "accountType": "checking",
      "bankName": "Example Bank"
    }
  }'
```

Each bank type takes its own named object:

* `bankUs`: `accountNumber` and `routingNumber` (nine-digit ABA) required; `accountType` (`checking` or `savings`, default `checking`) and `bankName` optional. Send the account's real type: an ACH payout to the wrong type can be returned by the receiving bank.
* `bankIban`: `iban` and `BIC` required; `bankAddress` and `countryCode` optional (the country is derived from the first two letters of the IBAN when omitted).
* `bankCanada`: `institutionNumber`, `transitNumber`, and `accountNumber` required; `bankName` optional.

The response returns the `ext_` ID:

```json theme={null}
{
  "id": "ext_01H9X...",
  "type": "bankUs",
  "customer": { "id": "cst_01H9Xa..." },
  "asset": "usd",
  "supportedDestinations": [
    { "asset": "usd", "rail": "ach" },
    { "asset": "usd", "rail": "achSameDay" },
    { "asset": "usd", "rail": "wire" }
  ],
  "details": {
    "bankName": "Example Bank",
    "accountNumberLast4": "9012",
    "routingNumber": "021000021",
    "accountType": "checking",
    "holderName": "Alice Smith"
  },
  "status": "pending",
  "metadata": {},
  "createdAt": "2026-01-15T10:02:00.000Z",
  "updatedAt": "2026-01-15T10:02:00.000Z"
}
```

The account starts `pending` and moves to `active` when provisioning completes, or to `failed` or `rejected` with a `failure` object that says why. Wait for `active` before you quote against it; the `externalAccount.statusChanged` webhook tells you when it changes.

* `details` holds the type-specific values. Sensitive values are write-only: reads expose `details.accountNumberLast4` (or `details.ibanLast4` for IBAN accounts), never the full number or IBAN.
* `supportedDestinations` lists the rails a US bank account can be paid on, narrowed by what your project enables. Pick the quote's `network` from this list.
* `owner` appears only when a counterparty owns the account; a customer's own account has just `customer`.

List accounts with `GET /external-accounts` (filter by `customerId`, `counterpartyId`, or `status`), update `label` or `metadata` with `PATCH /external-accounts/{externalAccountId}`, and remove an account with `DELETE /external-accounts/{externalAccountId}`. Bank details are immutable: to change them, register a new account and point your flows at it.

> [!NOTE]
>
> To pay a bank account owned by someone other than your customer, create a counterparty first with `POST /counterparties` (requires `customerId`, `entityType`, and the matching name fields: `firstName` and `lastName` for an `individual`, or `businessName` for a `business`), then register the account with `owner: { "kind": "counterparty", "counterpartyId": "ctp_..." }`. Whether `address` is required depends on your project. See [Pay a third party](https://docs.polygon.technology/payments/guides/third-party-payouts.md) for the full walkthrough.

### Step 2: Create a quote

The source is the customer's OMS wallet; the destination is the registered bank account. Set `amount` on exactly one side and OMS computes the other. For `bankUs`, `network` selects the rail: `ach`, `achSameDay`, `wire`, or `rtp`, subject to what the account lists in `supportedDestinations` and what your project enables. An unavailable rail returns `422` with `BankRailNotSupported`.

```bash Sandbox theme={null}
curl -X POST https://sandbox-api.polygon.technology/v0.13/quotes \
  -H "Authorization: Bearer {accessToken}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: qt-bank-out-001" \
  -d '{
    "customerId": "cst_01H9Xa...",
    "source": {
      "type": "walletCrypto",
      "details": { "id": "wlt_01H9Xb...", "asset": "usdc", "network": "polygon" },
      "amount": "100.00"
    },
    "destination": {
      "type": "bankUs",
      "details": {
        "id": "ext_01H9X...",
        "asset": "usd",
        "network": "ach"
      }
    }
  }'
```

```bash Production theme={null}
curl -X POST https://api.polygon.technology/v0.13/quotes \
  -H "Authorization: Bearer {accessToken}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: qt-bank-out-001" \
  -d '{
    "customerId": "cst_01H9Xa...",
    "source": {
      "type": "walletCrypto",
      "details": { "id": "wlt_01H9Xb...", "asset": "usdc", "network": "polygon" },
      "amount": "100.00"
    },
    "destination": {
      "type": "bankUs",
      "details": {
        "id": "ext_01H9X...",
        "asset": "usd",
        "network": "ach"
      }
    }
  }'
```

The quote returns in `open` status with locked `pricing`. Show the customer `pricing.destination.amount` and any entries in `pricing.fees` before they confirm. Quotes are short-lived (30 seconds by default, set per project): accept it before `expiresAt`, or create a new one. Accepting an expired quote returns `422` with `QuoteExpired`.

> [!TIP]
>
> To send a payment reference with the payout, add `memo` to the destination `details`. On `ach` and `achSameDay` it is limited to 80 NACHA characters; on `wire` and `rtp`, 140 ISO 20022 characters.

### Step 3: Execute the transaction

Accept the quote by creating a transaction. The request body references the quote ID with `quoteId`: the quote is the contract.

```bash Sandbox theme={null}
curl -X POST https://sandbox-api.polygon.technology/v0.13/transactions \
  -H "Authorization: Bearer {accessToken}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: txn-bank-out-001" \
  -d '{ "quoteId": "qt_01H9Xq..." }'
```

```bash Production theme={null}
curl -X POST https://api.polygon.technology/v0.13/transactions \
  -H "Authorization: Bearer {accessToken}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: txn-bank-out-001" \
  -d '{ "quoteId": "qt_01H9Xq..." }'
```

OMS pulls USDC from the wallet, converts it, and sends the bank payout. The transaction starts `processing` and moves to `completed` when the payout settles, or `failed` with a `failure` object. Track it with the `transaction.statusChanged` webhook (the transaction is in `data.resource`) or by polling `GET /transactions/{transactionId}`. Branch on `status`; use `subStatus` for detail such as `processing.fiatOut` or `processing.payoutRetrying`.

### Step 4: Read the bank rail and tracking references

The transaction's source and destination describe the accounts, not the rail. How the money moved lives on the transaction's transfers. Once the payout starts, `transfers[]` lists each one with its `id` (`trf_`), `role`, and `rail`. Fetch the payout transfer for the full record:

```bash theme={null}
curl https://sandbox-api.polygon.technology/v0.13/transfers/trf_01H9Xt... \
  -H "Authorization: Bearer {accessToken}"
```

```json theme={null}
{
  "id": "trf_01H9Xt...",
  "transactionId": "txn_01H9Xr...",
  "role": "payout",
  "rail": "ach",
  "attempt": 1,
  "status": "settled",
  "amount": { "amount": "99.50", "asset": "usd" },
  "from": { "type": "walletCrypto", "id": "wlt_01H9Xb...", "asset": "usdc", "network": "polygon" },
  "to": { "type": "bankUs", "id": "ext_01H9X...", "asset": "usd" },
  "secCode": "ppd",
  "createdAt": "2026-01-15T10:05:00.000Z",
  "updatedAt": "2026-01-15T11:40:00.000Z",
  "settledAt": "2026-01-15T11:40:00.000Z"
}
```

* `rail`, `secCode` (ACH only), `companyDiscretionaryData`, and `originatorName` describe how the payout was sent.
* `tracking` carries bank references such as `imad` (Fedwire) or `uetr` (SWIFT) as soon as OMS holds them. Share these with the customer or their bank when tracing a payment.
* A payout the receiving bank sends back ends `returned`, with the bank's reason in `return.bankCode` (for example `R03`).
* A retry after a confirmed failure is a new transfer with `attempt` incremented; the failed one stays `failed`.

Subscribe to `transfer.statusChanged` to follow transfers without polling.

## Receive a bank deposit into a wallet

Inbound bank money arrives through a virtual account, not a quote. Provision a dedicated bank account number per customer, share the routing details from `depositInstructions.bankUs[]`, and OMS converts each deposit automatically. Every deposit creates a transaction whose `precursor` is `{ "type": "virtualAccount", "id": "va_..." }`. Follow it with `transaction.statusChanged`, and the inbound bank leg with `transfer.statusChanged` (a transfer with `role: deposit`). To list a customer's bank-funded transactions, filter `GET /transactions?sourceToDestination=fiatAccountToCrypto`.

See the [Virtual accounts](https://docs.polygon.technology/payments/guides/virtual-accounts.md) guide for the full flow.

## Beneficiary requirements

* Banks are referenced with standard identifiers: ABA routing numbers for US accounts, institution and transit numbers for Canada, and IBAN plus BIC for SWIFT. There is no bank catalog to query.
* Call `GET /reference/account-type-requirements` to see which account types your project can create and which owner and detail fields each one needs.
* Every beneficiary is screened before payout.

## Limits

Per-rail limits for a US bank account appear on the account itself, as `minimumAmount` and `maximumAmount` in `supportedDestinations`. Your project can also carry per-transaction and rolling daily, weekly, or monthly limits; a quote over one returns `422` (for example `DailyLimitExceeded` or `MonthlyLimitExceeded`). A payment held for review stays `processing` with `subStatus` `processing.underReview` rather than failing.

## Key points

* **A quote's source is an OMS wallet or a card.** A bank account is only a destination. There is no bank-funded quote.
* **Register the bank, then reference it by ID.** Create it with `POST /external-accounts` (an `owner`, a `type`, and exactly one per-type object such as `bankUs`), then use the returned `ext_` ID in the quote destination. Counterparty-owned accounts use `owner: { "kind": "counterparty", "counterpartyId": "..." }` after a `POST /counterparties`.
* **Pick the rail with `network`.** For `bankUs`, use `ach`, `achSameDay`, `wire`, or `rtp`. IBAN routes over `swift`; Canadian USD over `swift`, CAD over `local`.
* **The rail and bank references live on the transfer.** Read `rail`, `secCode`, and `tracking` from `GET /transfers/{transferId}`, not from the transaction.
* **Inbound uses virtual accounts.** An ACH or wire deposit into a wallet comes through a virtual account, which creates the transaction for you.
* **Track with `transaction.statusChanged` and `transfer.statusChanged`.** The resource is in `data.resource`; branch on `status`. See [Webhook events](https://docs.polygon.technology/api-reference/webhook-events.md) for the envelope and the full catalog.
* **Idempotency keys are required on every `POST`.** Use one key per logical request and reuse it only to retry that same request.
