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 {accessToken} on every request. Every POST request also requires an Idempotency-Key header. See Get started for the full flow.- Pay out from a wallet to a bank account (USDC to fiat): run the
POST /quotesthenPOST /transactionspattern 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. The customer sends an ACH or wire transfer to the dedicated account number, and OMS converts the deposit to crypto automatically.
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 referenced by itsext_ 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.
bankUs:accountNumberandroutingNumber(nine-digit ABA) required;accountType(checkingorsavings, defaultchecking) andbankNameoptional. Send the account’s real type: an ACH payout to the wrong type can be returned by the receiving bank.bankIban:ibanandBICrequired;bankAddressandcountryCodeoptional (the country is derived from the first two letters of the IBAN when omitted).bankCanada:institutionNumber,transitNumber, andaccountNumberrequired;bankNameoptional.
ext_ ID:
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.
detailsholds the type-specific values. Sensitive values are write-only: reads exposedetails.accountNumberLast4(ordetails.ibanLast4for IBAN accounts), never the full number or IBAN.supportedDestinationslists the rails a US bank account can be paid on, narrowed by what your project enables. Pick the quote’snetworkfrom this list.ownerappears only when a counterparty owns the account; a customer’s own account has justcustomer.
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.
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 for the full walkthrough.Step 2: Create a quote
The source is the customer’s OMS wallet; the destination is the registered bank account. Setamount 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.
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.
Step 3: Execute the transaction
Accept the quote by creating a transaction. The request body references the quote ID withquoteId: the quote is the contract.
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:
rail,secCode(ACH only),companyDiscretionaryData, andoriginatorNamedescribe how the payout was sent.trackingcarries bank references such asimad(Fedwire) oruetr(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 inreturn.bankCode(for exampleR03). - A retry after a confirmed failure is a new transfer with
attemptincremented; the failed one staysfailed.
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 fromdepositInstructions.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 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-requirementsto 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, asminimumAmount 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(anowner, atype, and exactly one per-type object such asbankUs), then use the returnedext_ID in the quote destination. Counterparty-owned accounts useowner: { "kind": "counterparty", "counterpartyId": "..." }after aPOST /counterparties. - Pick the rail with
network. ForbankUs, useach,achSameDay,wire, orrtp. IBAN routes overswift; Canadian USD overswift, CAD overlocal. - The rail and bank references live on the transfer. Read
rail,secCode, andtrackingfromGET /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.statusChangedandtransfer.statusChanged. The resource is indata.resource; branch onstatus. See Webhook events 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.