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 {accessToken} on every request. Every POST that creates or changes a resource also requires an Idempotency-Key header. See Get started for the full flow.
This guide moves USDC out of a customer’s custodial wallet with a quote and a transaction. The crypto send comes first; a bank payout follows the same pattern and is covered at the end. For how quotes lock pricing, see Quote system.
Send from a wallet flow
1App→OMSPOST /quotes (USDC/polygon → USDC/polygon)
2OMS→AppQuote with locked pricing
3App→OMSPOST /transactions {quoteId}
4OMS→PolygonPull USDC and send onchain
5OMS→AppWebhook: transaction.statusChanged
6OMS→AppWebhook: transaction.statusChanged

Prerequisites

Before sending USDC from a wallet, you need:
  1. A customer with a cst_ ID whose cryptoCustody endorsement is active. Otherwise POST /quotes returns 403 KYCRequired.
  2. A funded OMS wallet with a wlt_ ID containing enough USDC to cover the source amount plus gas (if not sponsoring gas).
  3. A destination: another OMS wallet (its wlt_ ID) or an external onchain address (a 0x... address on the same network).
  4. A webhook endpoint registered with POST /webhooks (or in the OMS Dashboard) to receive transaction events.

Step 1: Create a quote

Create a quote to lock in the pricing. OMS infers the direction as cryptoToCrypto from the source and destination instruments (an OMS wallet to a crypto address). Each side of a quote request is a typed instrument: a type plus a details object. The quote source must be an OMS wallet (walletCrypto) or a card. The destination describes where the funds land. For a crypto payout, use a walletExternal instrument: either details.blockchainAddress for a raw address, or details.id for a registered external wallet (ext_...). Set amount on exactly one side; OMS calculates the other. Responses flatten each instrument: type, id, asset, and network sit at the top level of source and destination. An owner names the counterparty or other customer that owns the account when it is not your customer’s own, and a typed details block carries the display fields for that instrument type, such as a wallet address or a bank accountNumberLast4.

Request

Required fields:
  • customerId: The customer who owns this transaction.
  • source: A typed instrument. Set type to walletCrypto and put the OMS wallet id, asset (usdc or usdt), and the wallet’s network in details.
  • destination: A typed instrument naming where the funds land. For a crypto payout, use type: "walletExternal" with either details.blockchainAddress (a raw address) or details.id (a registered external wallet, ext_ prefix), plus asset and network.
  • amount on exactly one side: Here we set it on the source (send 2,000 USDC). OMS calculates the destination amount after fees. Set it on the destination instead to target an exact received amount.
Optional fields:
  • sponsorGas: When true, OMS absorbs gas fees. The customer sees no gas entry in pricing.fees and the response carries pricing.sponsorGas: true (an out-of-band developer cost on your account). At launch gas is always sponsored.
  • metadata: Up to 20 arbitrary key-value pairs.

Response, 201 Created

Key fields in the response:
  • id: The quote ID (prefix qt_). You pass it to create the transaction.
  • status: "open": Pricing is locked, awaiting acceptance.
  • sourceToDestination: "cryptoToCrypto": The direction OMS inferred from the instruments.
  • customerId and customer: the owning customer. A quote carries the customer’s name for display; read status and endorsements from the customer resource.
  • source and destination: Typed instruments. The source is the customer’s own OMS wallet, so no owner is reported. The destination is an unregistered external wallet, so it carries no id; the recipient’s on-chain address is in destination.details.address.
  • pricing: All money lives here. pricing.destination.amount is what the destination receives. At launch OMS absorbs fees and gas, so pricing.fees is empty and pricing.sponsorGas is true (the field is omitted when gas is not sponsored).
  • All amounts are decimal strings padded to the asset’s precision; the same amount renders the same string on every network.
  • expiresAt: Pricing is locked until this time, 30 seconds after creation by default. A transaction against an expired quote fails with 422 QuoteExpired, and against a used one with 422 QuoteConsumed.
If the customer accepts the pricing, proceed to Step 2. If the quote expires, create a new one. If the pair of instruments is not supported, POST /quotes returns 422 UnsupportedInstrumentCombination with the destinations the source does support.

Step 2: Create the transaction

Accept the quote by creating a transaction that references the quote ID. This is the point of no return: USDC is pulled from the wallet and the onchain send begins.

Request

The request body is intentionally thin: the quote is the contract. All source, destination, and pricing details were locked in Step 1. The transaction ID prefix is txn_.

Response, 201 Created

What to notice:
  • status: "processing": USDC has been pulled from the wallet and the onchain send is underway.
  • subStatus: "processing.fundsPulled": Always-present operational detail in dot notation, indicating the USDC has been pulled. The next sub-status reflects send progress. Branch on status alone.
  • payoutOrigin: Names where the payout is sent from. For a crypto send, type: "blockchain" and id is the source OMS wallet.
  • No transfers array yet: it appears once OMS creates the payout transfer.
  • pricing.sponsorGas: true: OMS absorbed the gas cost; it is a separate, out-of-band developer cost on your account and does not appear in pricing.fees.
  • No hold is present because the transaction has never been held. If a transaction needs you to act, status becomes awaitingAction and hold says what to do.

Webhook: transaction.statusChanged

OMS sends transaction.created when the transaction is created, then transaction.statusChanged on every later change of status or subStatus. The full transaction arrives under data.resource:
Branch on data.resource.status. data.previousStatus names the status it left; when only subStatus changed, it equals the current status. Deduplicate on id and idempotencyKey, and ignore an event whose createdAt is not newer than the last one you applied for this transaction. See Receiving webhooks.

Step 3: Track the transaction

OMS broadcasts the onchain send, and the funds arrive once it confirms. OMS sends transaction.statusChanged again when the transaction reaches completed:
What changed from the processing state:
  • status is now completed and subStatus is completed.settled.
  • updatedAt reflects when the onchain send confirmed.
  • transfers lists the payout transfer. Fetch it with GET /v0.13/transfers/{transferId} to read the on-chain hash in tracking.txHash. See Transfers.
At this point the flow is done. 2,000 USDC was pulled from the custodial wallet and delivered to the destination address. OMS absorbed the fees and the gas cost, and pricing.sponsorGas is true on the response.

Polling alternative

If you prefer polling over webhooks, retrieve the transaction directly:
Poll until status is completed or failed. Webhooks are preferred for production: they avoid unnecessary requests and notify you the moment status changes.

Failure handling

If the transaction fails after USDC has been pulled (for example, an onchain error or a compliance block), status moves to failed and OMS fires transaction.statusChanged. The failure object in data describes what went wrong:
failure.code names what went wrong, failure.message is a fixed, vendor-neutral sentence for that code, and failure.retryable tells you whether trying the same operation again can succeed. Use them to decide whether to retry or surface the failure to the customer.

Returns and refunds

A bank payout that fails or is returned after the funds left the wallet (an invalid account, a bank rejection, a return code) does not strand the money. Because fiat payouts are funded from the wallet’s stablecoin balance, a failed payout re-credits the stablecoin side automatically; there is nothing to request. The payout transfer reads failed or returned. subStatus reads processing.payoutRetrying while OMS sends a failed payout again, processing.returningToSender while the funds are on their way back, and failed.returnedToSender once they are returned. When the receiving bank returns a settled payout, the money comes back to OMS and the transaction reads processing.underReview while OMS decides what happens next. transaction.statusChanged fires on each status change. Watch the same transaction rather than creating a compensating transfer yourself: crediting your user once when status turns failed and again when the funds come back is the classic double-credit bug. These rails have no card-style chargeback exposure: OMS does not enable ACH pull, so an inbound deposit cannot be clawed back the way a card payment can.

Holds and compliance review

A compliance review does not fail the transaction. It stays processing with subStatus processing.underReview until OMS releases it. The move into review sends transaction.statusChanged with previousStatus equal to processing; no reason is given. A transaction that needs you to act moves to awaitingAction, and its hold object carries a code and a fixed message. The hold codes, senderAttribution and payoutDestinationMissing, cover deposits that arrive through a deposit address or virtual account; see the transaction lifecycle for what releases each one. awaitingAction is non-terminal. Once the hold clears, the transaction returns to processing and continues to completed or failed, firing the corresponding transaction event. Branch on status: a transaction in awaitingAction is not done.

Webhook events

Both flows in this guide report through the same events. See Webhook events for the envelope and the full catalog.

Key points

  • The quote is the contract. The transaction request body is just { "quoteId": "..." }: no overrides.
  • Sides are typed instruments. A quote request source is walletCrypto or card; the crypto destination is walletExternal, either with details.blockchainAddress for a raw address or details.id for a registered external wallet.
  • Responses carry a typed details block. id, asset, and network sit on the response side directly, and the type’s labeling fields (an on-chain address, a bank accountNumberLast4, a card brand) live in details. Pick the fields you want to render.
  • Money lives in pricing. The source and destination sides carry identity and instrument only. Read amounts and the fee breakdown from pricing; every amount is padded to its asset’s precision.
  • Gas can be sponsored. With sponsorGas: true, pricing.sponsorGas is true and no gas fee appears in pricing.fees; the gas cost is billed to your account separately.
  • Idempotency keys are per request. Use a distinct Idempotency-Key for the quote and the transaction so retries are safe and don’t create duplicates.

Pay out to a bank account

A fiat payout follows the same two-step quote and transaction flow. The quote source is still an OMS wallet holding USDC. The difference is the destination: instead of a crypto instrument, you target an external bank account. OMS infers the direction as cryptoToFiatAccount. Register the bank account first with POST /external-accounts: pass an owner ({ "kind": "customer", "customerId": "..." }), type: "bankUs", and a bankUs object with the account and routing numbers. OMS returns the external account’s ext_ ID; the account starts pending and flips to active when provisioning completes. Reference that ID in the quote’s destination, with asset: "usd" and a network of ach, achSameDay, wire, or rtp (where enabled for your project). Optionally add a memo for the beneficiary’s bank (ACH up to 80 characters; wire and RTP up to 140). The payout requires the customer’s usd endorsement to be active.
That is the request body. The response comes back with type, id, asset, and network on the destination directly, and a typed details block carrying the bank’s accountNumberLast4 (plus bankName, routingNumber, and holderName where known) for display. To read the full stored account, dereference the external account by id with GET /external-accounts/{id}. On the transaction, the bank destination carries no rail: the payout transfer in transfers reports the rail, and fetching it returns the rail reference in tracking (for example, imad on a wire). The top-level payoutOrigin names whose name the payout goes out under and does not inline the funding account’s identifiers. Once the quote is open, create the transaction the same way, with { "quoteId": "..." }. Track it with the transaction.statusChanged event, or by polling GET /v0.13/transactions/{transactionId}. ACH transfers typically settle in 1 to 3 business days; wire is faster (about an hour, within the Fedwire window). While the payout is queued and in flight, subStatus reads processing.awaitingFiatOut and then processing.fiatOut. The same operational notes apply: the quote is the contract, money lives in pricing, and you should use a distinct idempotency key per request. See Customer onboarding for the full external-account registration walkthrough.
For card rails, where the crypto lands is set by the quote’s destination. A card buy with a walletExternal destination delivers to that on-chain address; any other crypto destination delivers to the customer’s OMS-custodied wallet on the destination chain. A card sell always settles into the customer’s walletCrypto.