Skip to main content

Goal

Withdraw USDC from a customer’s Bastion account to a bank account outside the United States, using an off-ramp conversion. The recipient may be the customer (first-party) or someone else (third-party), and may be an individual or a business. Scope: USDC to local fiat via international payout rails in 60+ countries, first-party and third-party recipients. The customer holding the account must be a U.S. person; only the payout destination is international. To withdraw to a U.S. bank account, see Withdraw stablecoin to a US bank account.

Key entities

Flow overview

The steps to withdraw funds from a stablecoin balance to an international bank account are:
  1. Confirm identity, account, and balance
  2. Get a quote
  3. Look up institutions, if the corridor requires a bank selection
  4. Register payment instructions
  5. Submit the conversion
  6. Track status
  7. Show updated balance

Prerequisites

  • Customer has a verified identity (KYC or KYB) with fiat_operations_enabled = true
  • Customer has an on-chain account with a USDC balance on a supported chain
  • You have an API key and a registered webhook
  • The destination corridor is supported. See Payout corridors and required fields
International off-ramps are supported on Ethereum, Solana, Base, and Polygon. See Supported chains and assets.

Step 1 – Confirm identity, account, and balance

Before quoting, confirm the customer can transact and read their current balance. You need the balance to validate the withdrawal after quoting, and to support full-balance withdrawals. Get the identity
Verify these fields: Get balances
Balances are returned in base units. Divide by 10 ^ decimals to get the decimal amount: 750000000 with decimals: 6 is 750.000000 USDC. Quotes and conversions take decimal strings, not base units. Confirm available is greater than zero here. The sufficiency check happens in Step 2, once the quote returns the all-in debit.

Step 2 – Get a quote

Every international off-ramp must reference a quote. The quote returns the FX rate, the fee breakdown, the all-in debit from the customer’s wallet, and the corridor’s amount limits. Query the quote in the direction that matches what the customer entered: Request
Response
Query parameters Provide exactly one of source_amount, destination_amount, or source_total_max. Response fields After the quote returns:
  • Verify available from Step 1 covers source.total. Under ON_TOP the customer pays more than the amount they entered.
  • Validate the amount against limits. If the customer entered an amount in a currency other than limits.currency, convert using exchange_rate for client-side validation, or rely on server-side validation.
  • limits.min_amount applies to the payout amount and does not include fees. A customer sending the minimum needs the minimum plus fees in their wallet.
Re-fetch the quote whenever the customer’s input changes, and again before showing the confirmation screen. Each quote supports one successful conversion. Retries that reuse the same request_id are idempotent and do not consume a second quote.
On fee_percent: the DEVELOPER fee is a percentage of source.amount, calculated before FX. The fee_percent on that line is informational: amount is rounded to the asset’s displayed precision, so recomputing from the percentage can differ by a cent. Use amount for validation, ledgering, and reconciliation.

Full-balance withdrawals

Use source_total_max when the customer wants to empty their wallet under ON_TOP. There is no headroom above the balance to add fees, so Bastion solves the principal such that source.amount + fees_total fits inside the budget you pass. This keeps the fee arithmetic server-side, where a client-side calculation risks a rounding mismatch that fails the submit.
Submit the returned source.amount as amount. The returned source.total is less than or equal to source_total_max; rounding can leave a small remainder in the wallet. Precision: pass the full on-chain balance, not the figure your UI displays. If the wallet holds 50128456 base units (50.128456 USDC) and your UI rounds it to 50.12, treat a “send max” tap as intent to withdraw 50.128456 and pass that as source_total_max. Passing 50.12 leaves the remainder stranded and the balance will not reach zero.

Step 3 – Look up institutions

Some corridors require the customer to choose the receiving institution, and reject payment instructions submitted without a bank_id. Others accept any bank. Check Payout corridors and required fields for your corridor. If it does not require a bank selection, skip to Step 4. Request
Response
When all_institutions_supported is true, the institutions array is empty, any bank is accepted, and bank_id is not required. When it is false, present the returned list and pass the selected bank_id on registration. bank_id is an opaque string. Do not parse or construct it.
Caching: institution lists change infrequently, and a 24-hour TTL is a reasonable default. For countries returning long lists, use a search field rather than a dropdown.

Step 4 – Register payment instructions

Register the recipient’s bank account. The returned payment_instructions_id is reusable across withdrawals to the same bank account. Before building the request:
  1. Determine payment_method from the destination country and currency
  2. Check whether the corridor requires a bank_id (Step 3)
  3. Set beneficiary_type to FIRST_PARTY for the customer’s own bank account, or THIRD_PARTY for someone else’s
  4. Collect the fields for the corridor and nest them inside the {payment_method}_details object
  5. Set exactly one of recipient.individual or recipient.business. Some corridors require tax_id or phone, and some accept individual recipients only
All corridor field requirements are in Payout corridors and required fields. Request
Third-party payout to Mexico via SPEI:
Field notes Bastion derives the account holder’s name and address from the recipient object before forwarding to the payment provider. Do not put them inside bank_account_details. Corridors requiring a bank selection differ only inside bank_account_details. For Korea via BANK_TRANSFER, that object is:
Response
Store the id as payment_instructions_id. Reference it on the conversion and reuse it for future withdrawals to this bank account.

Step 5 – Submit the conversion

Submit a conversion that debits USDC from the customer’s account and pays out local fiat. Set quote_id to the id from Step 2, and set amount to the quoted source.amount. Do not submit source.total or the raw wallet balance. Under ON_TOP, Bastion adds fees on top of amount, so submitting the all-in figure double-counts them. Request
Fee terms are set on the quote and inherited through quote_id. This endpoint does not accept fee_model or developer_fee_percent. To change a fee term, request a new quote and submit that quote_id. payment_purpose is required and must describe the nature of the payout. See the API reference for valid values. destination.payment_method and destination.currency must match the payment_method and fiat_currency_symbol on the registered payment instructions. An expired quote returns QUOTE_EXPIRED. A quote that has already been used, or was priced for a different corridor, is also rejected. In each case, request a new quote and resubmit. Response - INITIATED

Step 6 – Track conversion status

Track the conversion via webhooks (recommended) or polling until it reaches a terminal state.

Option A: Webhooks

Configure a webhook endpoint in the Bastion dashboard to receive conversion events. Each event carries the conversion object in data, using the same shape as the submit response. See Conversion notifications for the full schema. The exchange_rate and destination.amount on COMPLETED reflect settlement and may differ from the quoted values. On FAILED and RETURNED, the event carries failure_reason with a human-readable description of what went wrong. Log it, and use it to decide between prompting the customer to correct bank details and routing to support. Do not display it to the customer verbatim. On RETURNED, also read refund_details:
  • refund_amount — USDC credited back to the customer’s account
  • deposit_id — internal identifier for the cross-app credit (useful for support and reconciliation)
  • failure_details.type — structured category (bank_rejected, expired, data_missing, other, or payout_returned) for routing and analytics
  • failure_details.description — provider detail; matches failure_reason when both are set
When present, refund_details.tx_hash is the on-chain hash of the cross-app refund credit from Bastion back to the customer. Status progression INITIATED to PROCESSING to PAYOUT_INITIATED to PAYOUT_IN_PROGRESS to COMPLETED Handle statuses: For canonical status definitions and recovery guidance, see Stablecoin and fiat conversions.
Webhook reliability: failed deliveries are retried with exponential backoff (15 s base interval, 10 min max). Deduplicate using data.id and data.status. If no webhook arrives within a reasonable window, poll GET /v2/conversions/{conversion_id}.

Option B: Polling

Poll GET /v2/conversions/{conversion_id} as a fallback. The response matches the submit response, including quote_id, fee_model, source.total, fees[], and destination.country.

Step 7 – Show updated balance

After the conversion completes, refresh balances and show the withdrawal in the customer’s history.
UI recommendations:
  • Decrease the visible USDC balance by source.total, which includes fees
  • Display the USDC debited, the fiat delivered, the destination currency, and the status
  • Show the destination with masked bank account details
  • On the receipt, show the exchange_rate and destination.amount from the COMPLETED event rather than the quoted values

Implementation notes

What’s next