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 mutating request (POST and PATCH) also requires an Idempotency-Key header. See Get started for the full flow.Prerequisites
Before sending USDC from a wallet, you need:- A customer with a
cst_ID. - A funded OMS wallet with a
wlt_ID containing enough USDC to cover the source amount plus gas (if not sponsoring gas). - A destination: another OMS wallet (its
wlt_ID) or an external onchain address (a0x...address on the same network). - 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 ascryptoToCrypto from the source and destination instruments (an OMS wallet to a crypto address).
Each side of a quote is a typed instrument: a type plus a details object. The quote source must be an OMS wallet (walletOms) or a card. The destination describes where the funds land. To send to an external onchain address, use a walletExternal instrument with details.blockchainAddress. To send to another OMS wallet, use walletOms with details.id. Set amount on exactly one side; OMS calculates the other.
Request
customerId: The customer who owns this transaction.source: A typed instrument. SettypetowalletOmsand put the OMS walletid,asset(usdcorusdt), andnetwork(polygon,ethereum,base) indetails.destination: A typed instrument naming where the funds land. For a crypto address, usetype: "walletExternal"with eitherdetails.blockchainAddress(a raw address) ordetails.id(a registered external wallet,ext_wlt_prefix), plusassetandnetwork. To send to another OMS wallet, usetype: "walletOms"withdetails.id.amounton 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.
sponsorGas: Whentrue, OMS absorbs gas fees. The customer sees source-side fees as zero and the actual gas cost appears inpricing.sponsorGasCost. At launch gas is always sponsored.metadata: Up to 20 arbitrary key-value pairs.
Response, 201 Created
id: The quote ID (prefixqt_). You pass it to create the transaction.status: "open": Pricing is locked, awaiting acceptance.sourceToDestination: "cryptoToCrypto": The direction OMS inferred from the instruments.source.partyanddestination.party: Identify who is on each side. Here the source is your customer (relationship: "customer") and the destination is an unregistered external address (relationship: "externalUnregistered").pricing: All money lives here.pricing.destination.amountNetis what the destination receives. At launch OMS absorbs fees and gas, so thefeesDeductedcomponents are"0.00"andpricing.sponsorGasistrue.pricing.sponsorGasCost: The estimated gas cost OMS absorbs. This is an out-of-band cost, not deducted from the destination amount.expiresAt: Pricing expires after a short window. Create a new quote if it lapses.
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
txn_.
Response, 201 Created
status: "processing": USDC has been pulled from the wallet and the onchain send is underway.subStatus: "processing.fundsPulled": Optional operational detail in dot notation, indicating the USDC has been pulled. The next sub-status reflects send progress. Branch onstatusalone.source.details.txHash: The onchain transaction hash for the send.pricing.sponsorGasCost: "0.42": Final gas cost (may differ slightly from the quote estimate).hold: null: No hold is in effect. If a transaction is blocked on the developer, an upstream provider, or compliance,statusbecomesawaitingActionandholdcarries the detail.
Webhook: transaction.cryptoToCrypto.processing
OMS fires this event when the transaction enters processing:
payload field carries the full transaction object in its new state; the event name tells you the state it entered. The envelope has more fields than shown here (schemaVersion, orgId, sequence, and others); see Webhook events for the full envelope and event catalog.
Step 3: Track the transaction
OMS pulls the USDC, broadcasts the onchain send, and the funds arrive at the destination address once the transaction confirms. OMS firestransaction.cryptoToCrypto.completed when the transaction reaches completed:
- The event name is
transaction.cryptoToCrypto.completed. payload.statusis nowcompletedandpayload.subStatusisnull.updatedAtreflects when the onchain send confirmed.
pricing.sponsorGasCost.
Polling alternative
If you prefer polling over webhooks, retrieve the transaction directly: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 a transaction.cryptoToCrypto.failed event. The error object in the payload describes what went wrong:
failed is terminal. Inspect error.code to decide how 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. The transaction’serror object reports what happened and how the funds come back:
error.recoverabletells you whether the funds are being returned.error.refundtracks the automatic return: theamount,asset,network, thedestinationWalletreceiving the re-credit, astatus(pending,completed,failed), and the on-chaintxHashonce delivered. 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.error.recoveryappears instead ofrefundin the rare case where funds sit unattributed upstream and an operator-driven recovery is required; it carries the reference identifiers and instructions for that process.
transaction.cryptoToFiat.refundCompleted when the re-credit is delivered, and transaction.cryptoToFiat.refundFailed if the re-credit itself fails and needs operator follow-up (the crypto-to-crypto equivalents are transaction.cryptoToCrypto.refundCompleted and .refundFailed).
Watch for the refund on the same transaction object rather than creating a compensating transfer yourself: crediting your user when status turns failed and again when refund.status turns completed 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
If a transaction is blocked on the developer, an upstream provider, or compliance, it moves toawaitingAction rather than failing. The event catalog has no dedicated hold event, so read the transaction to observe the state; the subStatus carries the operational detail, and the hold object is populated when a specific, typed hold applies:
hold.type is one of senderAttribution, depositAddressFrozen, or depositAddressInactive, and the object carries the fields for that reason. 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
Transaction events are namespaced by transfer type: a crypto send firestransaction.cryptoToCrypto.* events, and a bank payout fires transaction.cryptoToFiat.* events. These are the events for this guide’s flows; see Webhook events for the envelope and the full catalog.
| Event | When |
|---|---|
transaction.cryptoToCrypto.processing | A wallet-to-wallet or wallet-to-address send starts executing |
transaction.cryptoToCrypto.completed | The crypto was delivered to the destination |
transaction.cryptoToCrypto.failed | The send failed; see payload.error |
transaction.cryptoToCrypto.refundCompleted / .refundFailed | The refund for a failed send was delivered, or itself failed |
transaction.cryptoToFiat.processing | A bank payout starts executing |
transaction.cryptoToFiat.completed | The fiat was delivered to the bank account |
transaction.cryptoToFiat.failed | The payout failed; see payload.error |
transaction.cryptoToFiat.refundCompleted / .refundFailed | The stablecoin re-credit for a failed payout was delivered, or itself failed |
Key points
- The quote is the contract. The transaction request body is just
{ "quoteId": "..." }: no overrides. - Sides are typed instruments. A quote
sourceiswalletOmsorcard; thedestinationhere iswalletExternal. Send to another OMS wallet withwalletOmsanddetails.id, or to a raw address withwalletExternalanddetails.blockchainAddress. - Money lives in
pricing. Thesourceanddestinationsides carry identity and instrument only. Read amounts, rates, and fees from the top-levelpricingobject. - OMS absorbs fees and gas at launch.
feesDeductedcomponents are"0.00"andpricing.sponsorGasistrue. The gas cost OMS covers appears inpricing.sponsorGasCost, which may differ slightly between quote and transaction. details.txHashis populated on the source because OMS executes an onchain transaction to send the USDC.- Idempotency keys are per request. Use a distinct
Idempotency-Keyfor 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 quotesource 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 ext_bankUs_ ID; the account starts pending and flips to active when provisioning completes. Reference that ID in the quote’s destination, with asset: "usd", a fiat network (ach, achSameDay, wire, or rtp), and accountHolder: "customer" (the only accepted value). The payout requires the customer’s usd endorsement to be ACTIVE.
{ "quoteId": "..." }. Track it with the transaction.cryptoToFiat.processing, transaction.cryptoToFiat.completed, and transaction.cryptoToFiat.failed events, or by polling GET /v0.10/transactions/{transactionId}. ACH transfers typically settle in 1 to 3 business days; wire and rtp are faster. 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, set
settlementType on the quote to choose where the crypto lands: internal keeps it in OMS custody, external delivers it to an on-chain wallet. It defaults to external for a card buy and internal for a card sell, and is ignored for non-card rails.