> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bastion.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Off-ramp: Withdraw stablecoin internationally

> Withdraw USDC to a bank account in 60+ countries via off-ramp conversion: quote pricing, institution lookup, payment instructions, and status tracking.

## 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](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account).

### Key entities

| Entity               | Description                                                                                                 |
| -------------------- | ----------------------------------------------------------------------------------------------------------- |
| Your platform        | The product integrating Bastion's APIs                                                                      |
| Customer             | An individual or business your platform has onboarded (KYC/KYB-verified), with a funded account             |
| Identity             | The customer's record in Bastion                                                                            |
| Account              | A Bastion account linked to the identity that holds balances and transacts                                  |
| Corridor             | A destination country plus payment method combination (e.g. Mexico via SPEI)                                |
| Quote                | A priced conversion, including FX rate, fees, and corridor limits. Required on every international off-ramp |
| Institution          | A receiving bank in the destination country, identified by `bank_id`                                        |
| Payment instructions | The recipient's bank account details, registered once and reusable                                          |
| Conversion           | A value change between assets (e.g. USDC on Solana to MXN in a bank account) via `POST /v2/conversions`     |

### 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](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields)

International off-ramps are supported on Ethereum, Solana, Base, and Polygon. See [Supported chains and assets](/guides/concepts/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**

```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/identities/{identity_id}
Authorization: Bearer YOUR_API_KEY
```

Verify these fields:

| Field                     | Required Value |
| ------------------------- | -------------- |
| `status`                  | `ACTIVE`       |
| `fiat_operations_enabled` | `true`         |

**Get balances**

```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/accounts/{account_id}/balances
Authorization: Bearer YOUR_API_KEY
```

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "balances": [
    {
      "symbol": "USDC",
      "total": "750000000",
      "available": "750000000",
      "decimals": 6
    }
  ]
}
```

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:

| Customer enters                            | Use                  |
| ------------------------------------------ | -------------------- |
| A USDC amount to send                      | `source_amount`      |
| A fiat amount for the recipient to receive | `destination_amount` |
| Their full wallet balance ("send max")     | `source_total_max`   |

**Request**

```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/conversions/quotes?source_currency=USDC&source_chain=SOLANA_MAINNET&destination_currency=MXN&destination_country=MX&payment_method=SPEI&source_amount=500.00
Authorization: Bearer YOUR_API_KEY
```

**Response**

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "id": "qt_123",
  "expires_at": "2026-06-23T20:00:00Z",
  "locked": false,
  "fee_model": "ON_TOP",
  "source": {
    "amount": "500.00",
    "total": "506.50",
    "currency": "USDC",
    "chain": "SOLANA_MAINNET",
    "payment_method": "BLOCKCHAIN"
  },
  "destination": {
    "amount": "8620.00",
    "currency": "MXN",
    "payment_method": "SPEI",
    "country": "MX"
  },
  "exchange_rate": "17.24",
  "fees_total": "6.50",
  "fees_total_currency": "USDC",
  "fees": [
    { "type": "PLATFORM", "amount": "2.50", "currency": "USDC" },
    { "type": "DEVELOPER", "amount": "4.00", "currency": "USDC", "fee_percent": "0.80" }
  ],
  "limits": {
    "min_amount": "18.00",
    "max_amount": "",
    "currency": "MXN"
  }
}
```

**Query parameters**

| Parameter               | Required    | Type   | Description                                                                                                                                    |
| ----------------------- | ----------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `source_currency`       | Yes         | string | Source asset currency. `USDC`                                                                                                                  |
| `source_chain`          | Yes         | string | Blockchain network. e.g. `SOLANA_MAINNET`                                                                                                      |
| `destination_currency`  | Yes         | string | Destination fiat currency. e.g. `MXN`, `JPY`, `EUR`                                                                                            |
| `destination_country`   | Yes         | string | ISO 3166-1 alpha-2 country code. e.g. `MX`, `JP`                                                                                               |
| `payment_method`        | Yes         | string | Payment rail. e.g. `SPEI`, `BANK_TRANSFER`, `SEPA`                                                                                             |
| `source_amount`         | Conditional | string | USDC amount to convert                                                                                                                         |
| `destination_amount`    | Conditional | string | Fiat amount the recipient receives                                                                                                             |
| `source_total_max`      | Conditional | string | USDC budget to spend from the wallet, covering principal and fees. See [Full-balance withdrawals](#full-balance-withdrawals)                   |
| `fee_model`             | Optional    | string | `ON_TOP` or `DEDUCTED`. Omit to use your configured default                                                                                    |
| `developer_fee_percent` | Optional    | string | Overrides your configured developer fee rate on this quote. Percentage as a string decimal, e.g. `"0.80"` for 0.80%. Pass `"0.00"` to waive it |

Provide exactly one of `source_amount`, `destination_amount`, or `source_total_max`.

**Response fields**

| Field           | Description                                                                                                                                                                                                                                                  |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`            | Quote identifier. Pass as `quote_id` on `POST /v2/conversions`                                                                                                                                                                                               |
| `expires_at`    | RFC 3339 timestamp. Submitting after this time returns `QUOTE_EXPIRED`                                                                                                                                                                                       |
| `locked`        | Returns `false`. FX and fees on the quote are indicative, and the rate applied at settlement may differ from the quoted rate. Present quoted figures as estimates, and show the final values from the `COMPLETED` event on the receipt                       |
| `fee_model`     | Under `ON_TOP`, `source.total = source.amount + fees_total`, and the recipient receives the full quoted destination amount. Under `DEDUCTED`, `source.total = source.amount`, fees are taken out of the principal before FX, and the recipient receives less |
| `source.amount` | USDC principal that converts. Submit this value as `amount` on the conversion                                                                                                                                                                                |
| `source.total`  | All-in USDC debit from the wallet. Display on the confirmation screen. Do not submit it                                                                                                                                                                      |
| `fees[]`        | One entry per fee type. `PLATFORM` is the transaction cost. `DEVELOPER` is your configured markup                                                                                                                                                            |
| `fees_total`    | Sum of `fees[].amount`, denominated in `fees_total_currency`                                                                                                                                                                                                 |
| `exchange_rate` | Rate applied to the converting principal. Under `ON_TOP` that is `source.amount`. Under `DEDUCTED` it is `source.amount` minus `fees_total`                                                                                                                  |
| `limits`        | The destination country's payout minimum and maximum, set by the local payment network. An empty `max_amount` means no maximum is enforced. Both values are denominated in `limits.currency`                                                                 |

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.

```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/conversions/quotes?source_currency=USDC&source_chain=SOLANA_MAINNET&destination_currency=MXN&destination_country=MX&payment_method=SPEI&source_total_max=100.00
Authorization: Bearer YOUR_API_KEY
```

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "id": "qt_456",
  "expires_at": "2026-06-23T20:00:00Z",
  "locked": false,
  "fee_model": "ON_TOP",
  "source": {
    "amount": "96.72",
    "total": "99.99",
    "currency": "USDC",
    "chain": "SOLANA_MAINNET",
    "payment_method": "BLOCKCHAIN"
  },
  "destination": {
    "amount": "1667.45",
    "currency": "MXN",
    "payment_method": "SPEI",
    "country": "MX"
  },
  "exchange_rate": "17.24",
  "fees_total": "3.27",
  "fees_total_currency": "USDC",
  "fees": [
    { "type": "PLATFORM", "amount": "2.50", "currency": "USDC" },
    { "type": "DEVELOPER", "amount": "0.77", "currency": "USDC", "fee_percent": "0.80" }
  ]
}
```

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](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields) for your corridor. If it does not require a bank selection, skip to Step 4.

**Request**

```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/institutions?country=KR&currency=KRW&payment_method=BANK_TRANSFER
Authorization: Bearer YOUR_API_KEY
```

**Response**

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "country": "KR",
  "currency": "KRW",
  "payment_method": "BANK_TRANSFER",
  "all_institutions_supported": false,
  "institutions": [
    {
      "bank_id": "FW4qdFBLoU4WFWerI-0SBbAl3PfolkxLruCNVofPmrUoaf7Op-MouPDqqV04mQDri34",
      "name": "Korea Development Bank"
    },
    {
      "bank_id": "tFxhEg6Cvb7sOMaTGt_EerlvmQzElLJI1DAtGJOhCGhPpJpDDg",
      "name": "Jeju Bank"
    }
  ]
}
```

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](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields).

**Request**

```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
POST /v2/identities/{identity_id}/payment-instructions
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
```

Third-party payout to Mexico via SPEI:

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "request_id": "b7e2c1a0-3f4d-4a9b-8c12-1d6e5f8a2b34",
  "payment_method": "SPEI",
  "country": "MX",
  "fiat_currency_symbol": "MXN",
  "account_name": "Maria's BBVA Account",
  "beneficiary_type": "THIRD_PARTY",
  "bank_account_details": {
    "name": "BBVA Bancomer",
    "spei_details": {
      "clabe": "626899294090851234"
    }
  },
  "recipient": {
    "individual": {
      "first_name": "Maria",
      "last_name": "Lopez",
      "email": "maria.lopez@email.com",
      "tax_id": "HEGJ8427157M9",
      "address": {
        "street_line_1": "Av. Insurgentes Sur 1234",
        "city": "Mexico City",
        "postal_code": "03100",
        "country": "MX",
        "region_code": "CDMX"
      }
    }
  }
}
```

**Field notes**

| Field                       | Description                                                                                                     |
| --------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `request_id`                | UUID idempotency key. Reuse the same `request_id` on retries to avoid creating duplicate payment instructions   |
| `account_name`              | User-facing label for the saved payment method                                                                  |
| `beneficiary_type`          | `FIRST_PARTY` or `THIRD_PARTY`                                                                                  |
| `bank_account_details.name` | The receiving institution's name, not the recipient's name. Required on all corridors                           |
| `{payment_method}_details`  | Corridor-specific bank fields. The key matches the payment method, e.g. `spei_details`, `bank_transfer_details` |
| `recipient.individual`      | `first_name`, `last_name`, `email`, and `address` required. `middle_name` optional                              |
| `recipient.business`        | `business_name`, `email`, and `address` required                                                                |
| `recipient.*.address`       | The recipient's address, not the bank's                                                                         |

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:

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "name": "Korea Development Bank",
  "bank_transfer_details": {
    "account_number": "1234567890",
    "bank_id": "FW4qdFBLoU4WFWerI-0SBbAl3PfolkxLruCNVofPmrUoaf7Op-MouPDqqV04mQDri34"
  }
}
```

**Response**

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw",
  "payment_method": "SPEI",
  "country": "MX",
  "fiat_currency_symbol": "MXN",
  "account_name": "Maria's BBVA Account",
  "beneficiary_type": "THIRD_PARTY"
}
```

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**

```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
POST /v2/conversions
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
```

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "request_id": "c8f4b2e0-5093-4d7b-af26-8e9c301b4d65",
  "quote_id": "qt_123",
  "amount": "500.00",
  "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz",
  "payment_purpose": "PERSONAL_REMITTANCE",
  "source": {
    "currency": "USDC",
    "payment_method": "BLOCKCHAIN",
    "chain": "SOLANA_MAINNET"
  },
  "destination": {
    "currency": "MXN",
    "payment_method": "SPEI",
    "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw"
  }
}
```

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](/v2/api-reference/conversions/submit-conversion) 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.&#x20;

**Response - INITIATED**

```JSON theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "id": "3Gb7G2J33qhpt0hUSSJm7ZnttAC",
  "request_id": "c8f4b2e0-5093-4d7b-af26-8e9c301b4d65",
  "quote_id": "qt_123",
  "account_id": "36hv3yZpsHt7pwgBZom5SXgURYz",
  "amount": "500.00",
  "type": "OFF_RAMP",
  "status": "INITIATED",
  "payment_purpose": "PERSONAL_REMITTANCE",
  "fee_model": "ON_TOP",
  "source": {
    "amount": "500.00",
    "total": "506.50",
    "currency": "USDC",
    "payment_method": "BLOCKCHAIN",
    "chain": "SOLANA_MAINNET"
  },
  "destination": {
    "amount": "8620.00",
    "currency": "MXN",
    "payment_method": "SPEI",
    "payment_instructions_id": "3Cb0rR32xNl2GAHFjJc4EHEcdPw",
    "country": "MX"
  },
  "exchange_rate": "17.24",
  "fees_total": "6.50",
  "fees_total_currency": "USDC",
  "fees": [
    { "type": "PLATFORM", "amount": "2.50", "currency": "USDC" },
    { "type": "DEVELOPER", "amount": "4.00", "currency": "USDC", "fee_percent": "0.80" }
  ],
  "created_at": "2026-03-01T15:00:00Z",
  "updated_at": "2026-03-01T15:00:00Z",
  "completed_at": ""
}
```

### 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](/v2/api-reference/webhooks/notifications/conversions-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:

| Status                                   | Action                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INITIATED`, `PROCESSING`                | Show a processing indicator; do not allow double-spend                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `PAYOUT_INITIATED`, `PAYOUT_IN_PROGRESS` | Show that funds are on their way to the destination bank; update the USDC balance                                                                                                                                                                                                                                                                                                                                                                                      |
| `COMPLETED`                              | Mark the withdrawal complete, refresh balances, and display the final `exchange_rate` and `destination.amount`                                                                                                                                                                                                                                                                                                                                                         |
| `FAILED`                                 | Show an error, allow retry, or direct to support. Read `failure_reason`                                                                                                                                                                                                                                                                                                                                                                                                |
| `RETURNED`                               | Read `failure_reason` and `refund_details` (amount, `deposit_id`, `failure_details`). If `failure_details.type` is `bank_rejected`, prompt the customer to verify the recipient's bank details and register corrected payment instructions before retrying. For `expired`, have the customer start a new withdrawal with a fresh quote. For `data_missing`, `other`, or `payout_returned`, route to support and use `failure_details.description` to decide next steps |

For canonical status definitions and recovery guidance, see [Stablecoin and fiat conversions](/v2/api-reference/conversions/overview).

> **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.

```Bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
GET /v2/accounts/{account_id}/balances
Authorization: Bearer YOUR_API_KEY
```

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

| Recommendation                               | Details                                                                                                                                                                                                                         |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Quote before every submit                    | Re-fetch when input changes and before the confirmation screen. Handle `QUOTE_EXPIRED` by re-quoting and resubmitting with the new `quote_id`                                                                                   |
| Check `source.total`, not the entered amount | Under `ON_TOP` the customer pays more than they entered. Verify the balance covers `source.total` before submitting                                                                                                             |
| Convert base units carefully                 | Balances are returned in base units; quotes and conversions take decimal strings. Use the full on-chain precision for full-balance withdrawals                                                                                  |
| Use idempotent request IDs                   | Provide a unique `request_id` per conversion and per payment instruction registration. Reuse it on retries                                                                                                                      |
| Treat fee amounts as authoritative           | Use `fees[].amount` for validation and reconciliation. `fee_percent` is for display                                                                                                                                             |
| Validate against corridor limits             | `limits.min_amount` is always populated and excludes fees. An empty `max_amount` means no maximum                                                                                                                               |
| Handle failures clearly                      | Treat `FAILED` and `RETURNED` as errors requiring customer messaging and potential manual review. Both carry `failure_reason`; `RETURNED` also carries `refund_details` for the credited amount and structured failure metadata |
| Mirror in your database                      | Store `identity_id`, `account_id`, `payment_instructions_id`, and `quote_id` alongside your internal records so webhooks route to the right customer                                                                            |
| Reuse payment instructions                   | `payment_instructions_id` is reusable across withdrawals to the same bank account                                                                                                                                               |
| Confirm the recipient                        | For third-party payouts, show the recipient's name and masked account details on the confirmation screen so the customer can catch entry errors before submitting                                                               |

### What's next

* [Payout corridors and required fields](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields)
* [Withdraw stablecoin to a US bank account](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account)
* [Stablecoin and fiat conversions](/v2/api-reference/conversions/overview)
* [Jurisdiction overview](/guides/getting-started/jurisdiction-overview)

<br />


## Related topics

- [Off-ramp: Withdraw stablecoin to a bank account](/guides/quickstarts/off-ramp.md)
- [Off-ramp: Withdraw stablecoin to a US bank account](/guides/quickstarts/off-ramp/withdraw-to-a-bank-account.md)
- [Stablecoin on-ramp and off-ramp conversions](/guides/concepts/stablecoin-conversions.md)
- [Supported off-ramp payout countries](/guides/getting-started/supported-off-ramp-payout-countries.md)
- [Off-ramp payout countries and required fields](/v2/api-reference/conversions/off-ramp-payout-countries-and-required-fields.md)
