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:- Confirm identity, account, and balance
- Get a quote
- Look up institutions, if the corridor requires a bank selection
- Register payment instructions
- Submit the conversion
- Track status
- 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
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
Get balances
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
Provide exactly one of
source_amount, destination_amount, or source_total_max.
Response fields
After the quote returns:
- Verify
availablefrom Step 1 coverssource.total. UnderON_TOPthe customer pays more than the amount they entered. - Validate the amount against
limits. If the customer entered an amount in a currency other thanlimits.currency, convert usingexchange_ratefor client-side validation, or rely on server-side validation. limits.min_amountapplies to the payout amount and does not include fees. A customer sending the minimum needs the minimum plus fees in their wallet.
request_id are idempotent and do not consume a second quote.
Onfee_percent: theDEVELOPERfee is a percentage ofsource.amount, calculated before FX. Thefee_percenton that line is informational:amountis rounded to the asset’s displayed precision, so recomputing from the percentage can differ by a cent. Useamountfor validation, ledgering, and reconciliation.
Full-balance withdrawals
Usesource_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.
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 abank_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
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 returnedpayment_instructions_id is reusable across withdrawals to the same bank account.
Before building the request:
- Determine
payment_methodfrom the destination country and currency - Check whether the corridor requires a
bank_id(Step 3) - Set
beneficiary_typetoFIRST_PARTYfor the customer’s own bank account, orTHIRD_PARTYfor someone else’s - Collect the fields for the corridor and nest them inside the
{payment_method}_detailsobject - Set exactly one of
recipient.individualorrecipient.business. Some corridors requiretax_idorphone, and some accept individual recipients only
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:
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. Setquote_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
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 indata, 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 accountdeposit_id— internal identifier for the cross-app credit (useful for support and reconciliation)failure_details.type— structured category (bank_rejected,expired,data_missing,other, orpayout_returned) for routing and analyticsfailure_details.description— provider detail; matchesfailure_reasonwhen both are set
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 usingdata.idanddata.status. If no webhook arrives within a reasonable window, pollGET /v2/conversions/{conversion_id}.
Option B: Polling
PollGET /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.- 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_rateanddestination.amountfrom theCOMPLETEDevent rather than the quoted values
Implementation notes
What’s next
- Payout corridors and required fields
- Withdraw stablecoin to a US bank account
- Stablecoin and fiat conversions
- Jurisdiction overview