---
url: /docs/transfer-quotes.md
description: >-
  Create a transfer quote to lock in the source wallet, destination address,
  token, network, and amount before executing a transfer.
---

A transfer quote locks in the details of a stablecoin move before you execute it: the source managed wallet, the destination address, the token, the network, and the amount. Executing the [transfer](/docs/transfers) simply consumes this quote, it does not take any new parameters of its own.

`customer_token` must match `sender_token`, except for `USDC` and `EURC` on Base, which [convert into each other](#usdc-and-eurc-conversion-on-base). `customer_network` must match the source wallet's own network, except when both tokens are `USDC`: a USDC transfer can additionally cross chains using [Circle CCTP v2](#cross-chain-usdc-transfers-circle-cctp-v2).

## How it works

* **Source**: a managed wallet (`wallet_id`, `bl_...`) that holds the stablecoin balance. This is the only supported source, unlike payouts, which can also draw from an external blockchain wallet.
* **Destination**: any blockchain address, `customer_wallet_address`, normally on the same network as the source wallet; a USDC transfer can target a different [CCTP v2 network](#cross-chain-usdc-transfers-circle-cctp-v2) instead. It does not need to belong to a BlindPay customer or wallet, it can be any address the recipient controls.
* **Expiry is 5 minutes**, fixed and not configurable. A transfer quote is meant to be executed immediately after creation, not held for later use: call [create a transfer](/docs/transfers) right after creating the quote, and request a new one if `expires_at` passes.

`expires_at` is returned in epoch milliseconds.

## Cross-chain USDC transfers (Circle CCTP v2)

When both `sender_token` and `customer_token` are `USDC`, `customer_network` can be a different network than the source wallet's. BlindPay bridges the move using [Circle's Cross-Chain Transfer Protocol v2](https://developers.circle.com/cctp): USDC is burned on the source chain and an equivalent amount is minted on the destination chain once Circle attests the burn.

Supported networks are Ethereum, Polygon, Base, Arbitrum, and Arc on a production instance; on a development instance, the matching testnets (Ethereum Sepolia, Polygon Amoy, Base Sepolia, Arbitrum Sepolia, Arc Testnet), using real testnet USDC rather than USDB, since CCTP can only move native USDC. Both the source and destination network must be on the same side of that mainnet/testnet boundary. Requesting a token other than USDC across networks returns `cross_chain_transfers_only_support_usdc`; requesting a network pair CCTP v2 does not support returns `cctp_route_not_supported`.

The quote itself is unaffected by the bridge: amounts stay 1:1 and BlindPay adds no fee on top, exactly like a same-network transfer. Any [partner fee](#partner-fees) you set applies the same way on both. Circle's protocol deducts its own small fee (typically a fraction of a cent up to a few cents, a few basis points of the amount) from the amount minted on the destination chain; this is not reflected in `receiver_amount` or any other quote field. Cross-chain transfers typically settle in 8 to 20 seconds; transfers sourced from Polygon settle in around 8 seconds either way.

Solana, Stellar, Tron, and Tempo are not part of cross-chain transfers yet; same-network moves on those chains are unaffected.

## USDC and EURC conversion on Base

A transfer can convert between `USDC` and `EURC` when the source wallet and `customer_network` are both `base`: set `sender_token` to one and `customer_token` to the other. On a development instance the same conversion runs between `USDB` and `EURB`, BlindPay's test tokens, on `base_sepolia`. No other network or token pair converts.

The quote is priced live by BlindPay's liquidity provider. `commercial_quotation` and `blindpay_quotation` carry the rate as units of `customer_token` per `sender_token`, times 100: `89.1` means 1 USDC = 0.891 EURC. `sender_amount` is in `sender_token` and `receiver_amount` in `customer_token`; any [partner fee](#partner-fees) is charged in `sender_token` before converting.

The smallest conversion is 10 USDC, measured on the USDC side whichever direction you convert, so selling about 9 EURC is enough. A smaller request fails with `400` `fx_amount_below_minimum`.

Prices move fast, so [executing the transfer](/docs/transfers#usdc-and-eurc-conversion) re-prices it: if the rate moved more than 0.5% against the customer since the quote, the transfer is refused with `409` `fx_rate_moved` and nothing moves. Create a new quote and try again. Otherwise the destination receives exactly `receiver_amount`.

## Prerequisites

You also need a [customer](/docs/kb/kyc) whose `kyc_status` is `approved`, `approved_rfi`, or `compliance_request`, and a [managed wallet](/docs/wallets) holding the stablecoin balance you want to move. This is wider than payins and payouts, which require `approved` or `approved_rfi` only: a customer paused by an open RFI (`compliance_request`) can still move funds already sitting in their managed wallet through a transfer, even though creating a new payin or payout for them stays blocked until the RFI is resolved. Any other `kyc_status` fails with `400` `kyc_not_approved`.

Creating a transfer quote also requires the caller to hold the transaction-create permission on the instance. Dashboard roles that have it: `owner`, `admin`, `finance`, `checker`, and `operations`. A caller without it gets a `403` `user_not_allowed` before `wallet_id` or any other field is checked.

## Request fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `wallet_id` | string (`bl_...`) | yes | The managed wallet the funds move out of. Must belong to your instance and not be deleted. |
| `amount_reference` | enum | yes | `sender` or `receiver`. Which side `request_amount` refers to. Only changes the result when a [partner fee](#partner-fees) is set; without one, `sender_amount` and `receiver_amount` both equal `request_amount`. |
| `request_amount` | integer | yes | Amount in minor units, no floats. `$100.00` sends as `10000`. Minimum is `1`. |
| `sender_token` | enum | yes | `USDC`, `USDT`, `USDB`, `EURC`, or `EURB`. Must be a token allowed on the source wallet's network. `EURC` and `EURB` are only available on `base` and `base_sepolia`. |
| `customer_token` | enum | yes | Must equal `sender_token`, except for a [USDC and EURC conversion on Base](#usdc-and-eurc-conversion-on-base). |
| `customer_network` | enum | yes | Must equal the source wallet's own network, unless `sender_token` and `customer_token` are both `USDC` and the pair is a [supported CCTP v2 route](#cross-chain-usdc-transfers-circle-cctp-v2). |
| `customer_wallet_address` | string | yes | The destination blockchain address, 32 to 64 characters, validated for the target network (see table below). Can be another wallet you created or any external address. |
| `cover_fees` | boolean | yes | Who pays the [partner fee](#partner-fees). `true`: the fee is added on top and the destination receives the full amount. `false`: the fee is deducted from the amount the destination receives. Has no effect without a partner fee. |
| `partner_percentage_fee` | integer | no | Partner percentage fee for this transfer, in hundredths of a percent: `150` is 1.50%, `1000` is 10.00%. `0` to `9900`. Defaults to `0`. |
| `partner_flat_fee` | integer | no | Partner flat fee for this transfer, in minor units of `sender_token`: `50` is 0.50, `1000` is 10.00. `0` to `100000`. Defaults to `0`. |
| `partner_fee_id` | string (`pf_...`) | no | Stored on the quote but not used for the fee amount. Transfers take the fee per request through `partner_percentage_fee` and `partner_flat_fee` instead. |

Address format by network family:

| Network family | Format | Example |
| --- | --- | --- |
| EVM (`ethereum`, `polygon`, `base`, `arbitrum`, `tempo`, and their testnets) | `0x` + 40 hex characters (42 total) | `0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372` |
| `stellar`, `stellar_testnet` | `G` + 55 base32 characters (56 total) | `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA` |
| `solana`, `solana_devnet` | base58, 32 to 44 characters | `11111111111111111111111111111111111111111` |
| `tron` | `T` + 33 base58 characters (34 total) | `TAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA` |

An address that doesn't match the format for `customer_network` fails with `VALIDATION_FAILED` and a message naming the field: `Invalid wallet address for network {network}`.

USDT transfers are currently restricted to Polygon and Solana, a tighter limit than payins and payouts allow for USDT.

## Partner fees

Set `partner_percentage_fee` and `partner_flat_fee` on the quote to charge your customer a fee on this transfer. The fee is set per request: transfers do not read the payin or payout rates of a [partner fee configuration](/docs/learn/partner-fees).

The total partner fee is the percentage fee plus the flat fee. The percentage is applied to the amount it is based on and rounded to the nearest minor unit. Amounts always add up: `sender_amount = receiver_amount + partner_fee_amount`.

| `amount_reference` | `cover_fees` | `sender_amount` (leaves the wallet) | `receiver_amount` (reaches the destination) |
| --- | --- | --- | --- |
| `sender` | `false` | `request_amount` | `request_amount` minus the fee |
| `sender` | `true` | `request_amount` plus the fee | `request_amount` |
| `receiver` | `false` | `request_amount` | `request_amount` minus the fee |
| `receiver` | `true` | Grossed up so that, after the percentage fee on `sender_amount` and the flat fee, exactly `request_amount` is left | `request_amount` |

Examples with `partner_percentage_fee: 1000` (10%) and `partner_flat_fee: 1000` (10.00) on a `request_amount` of `5000` (50.00):

| `amount_reference` | `cover_fees` | `sender_amount` | `receiver_amount` | `partner_fee_amount` |
| --- | --- | --- | --- | --- |
| `sender` | `false` | `5000` | `3500` | `1500` (5.00 + 10.00) |
| `sender` | `true` | `6500` | `5000` | `1500` |
| `receiver` | `true` | `6667` | `5000` | `1667` (10% of 66.67, plus 10.00) |

## Create a transfer quote

```bash [cURL]
curl https://api.blindpay.com/v1/instances/in_000000000000/transfer-quotes \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{
  "wallet_id": "bl_000000000000",
  "amount_reference": "sender",
  "cover_fees": true,
  "request_amount": 1000,
  "sender_token": "USDC",
  "customer_wallet_address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372",
  "customer_token": "USDC",
  "customer_network": "polygon",
  "partner_percentage_fee": 150,
  "partner_flat_fee": 50
}'
```

```js [index.js]
const response = await fetch(
  'https://api.blindpay.com/v1/instances/in_000000000000/transfer-quotes',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: 'Bearer YOUR_API_KEY',
    },
    body: JSON.stringify({
      wallet_id: 'bl_000000000000',
      amount_reference: 'sender',
      cover_fees: true,
      request_amount: 1000,
      sender_token: 'USDC',
      customer_wallet_address: '0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372',
      customer_token: 'USDC',
      customer_network: 'polygon',
      partner_percentage_fee: 150,
      partner_flat_fee: 50,
    }),
  },
)

const quote = await response.json()
```

### Examples by transfer type

The quote body is what decides the kind of transfer; [executing it](/docs/transfers#execute-a-transfer) is the same call for all three.

**Same network, same stablecoin.** USDC from a Base wallet to another Base address:

```bash [cURL]
curl https://api.blindpay.com/v1/instances/in_000000000000/transfer-quotes \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{
  "wallet_id": "bl_000000000000",
  "amount_reference": "sender",
  "cover_fees": false,
  "request_amount": 10000,
  "sender_token": "USDC",
  "customer_token": "USDC",
  "customer_network": "base",
  "customer_wallet_address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C"
}'
```

**Different networks, same stablecoin.** USDC from a Base wallet to a Polygon address, bridged with [Circle CCTP v2](#cross-chain-usdc-transfers-circle-cctp-v2):

```bash [cURL]
curl https://api.blindpay.com/v1/instances/in_000000000000/transfer-quotes \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{
  "wallet_id": "bl_000000000000",
  "amount_reference": "sender",
  "cover_fees": false,
  "request_amount": 10000,
  "sender_token": "USDC",
  "customer_token": "USDC",
  "customer_network": "polygon",
  "customer_wallet_address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C"
}'
```

**Same network, different stablecoins.** USDC from a Base wallet, delivered as EURC to a Base address. See [USDC and EURC conversion on Base](#usdc-and-eurc-conversion-on-base):

```bash [cURL]
curl https://api.blindpay.com/v1/instances/in_000000000000/transfer-quotes \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{
  "wallet_id": "bl_000000000000",
  "amount_reference": "sender",
  "cover_fees": false,
  "request_amount": 10000,
  "sender_token": "USDC",
  "customer_token": "EURC",
  "customer_network": "base",
  "customer_wallet_address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C"
}'
```

```json
{
  "id": "qu_000000000000",
  "expires_at": 1712958191000,
  "commercial_quotation": 86.5,
  "blindpay_quotation": 86.5,
  "receiver_amount": 8650,
  "sender_amount": 10000,
  "partner_fee_amount": 0,
  "partner_percentage_fee": 0,
  "partner_flat_fee": 0,
  "flat_fee": 0
}
```

100.00 USDC leaves the wallet and 86.50 EURC reaches the destination. Swap the two tokens to convert EURC into USDC.

## Response

```json
{
  "id": "qu_000000000000",
  "expires_at": 1712958191000,
  "commercial_quotation": 100,
  "blindpay_quotation": 100,
  "receiver_amount": 1000,
  "sender_amount": 1065,
  "partner_fee_amount": 65,
  "partner_percentage_fee": 150,
  "partner_flat_fee": 50,
  "flat_fee": 50
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string (`qu_...`) | Pass this as `transfer_quote_id` when you create the transfer. |
| `expires_at` | number | Epoch milliseconds. Execute the transfer before this passes. |
| `commercial_quotation` | number | Rate as units of `customer_token` per `sender_token`, times 100. `100` for every transfer that keeps the same token, including a cross-chain USDC move; the live rate for a [USDC and EURC conversion](#usdc-and-eurc-conversion-on-base). |
| `blindpay_quotation` | number | The rate applied to the transfer. Same as `commercial_quotation` for transfers today. |
| `receiver_amount` | number | Amount the destination receives, in minor units. See [partner fees](#partner-fees). |
| `sender_amount` | number | Amount that leaves the source wallet, in minor units: `receiver_amount` plus `partner_fee_amount`. |
| `partner_fee_amount` | number | Total partner fee for this transfer, percentage plus flat. `0` without a partner fee. |
| `partner_percentage_fee` | number | The percentage fee you requested, echoed back. |
| `partner_flat_fee` | number | The flat fee you requested, echoed back. |
| `flat_fee` | number | The flat part of the fee, same as `partner_flat_fee`. |

The `qu_` prefix is shared across payin quotes, payout quotes, and transfer quotes, you can't tell which kind of quote an ID refers to just by looking at it.

## Validation order

The API checks, in this order: the caller has permission to create transactions, the wallet exists and belongs to your instance. If `customer_network` differs from the source wallet's network, the API requires both `sender_token` and `customer_token` to be `USDC` (`cross_chain_transfers_only_support_usdc` otherwise) and the network pair to be a [supported CCTP v2 route](#cross-chain-usdc-transfers-circle-cctp-v2) (`cctp_route_not_supported` otherwise). If `customer_network` matches the source wallet's network, the API instead checks that the token and network are allowed for your instance type, that USDT is only used on Polygon or Solana, and that `sender_token` matches `customer_token` unless the pair is a [USDC and EURC conversion on Base](#usdc-and-eurc-conversion-on-base) (`sender_and_customer_tokens_must_be_the_same` otherwise). Either path then checks that the receiving customer's `kyc_status` is one of `approved`, `approved_rfi`, or `compliance_request`, and finally computes the [partner fee](#partner-fees) (`partner_fee_exceeds_transfer_amount` or `partner_fee_not_supported_on_network`) and, for a conversion, the live rate (`fx_amount_below_minimum` under the 10 USDC minimum, `fx_quote_unavailable` if no price can be obtained). The first failing check is the one returned.

## Testing

There is no dedicated test-amount sentinel for transfer quotes or transfers (unlike payins and payouts, which force outcomes at `$666.00` and `$777.00`). On development instances, transfer quotes go through the same token and network rules as production, restricted to the development tokens and testnets described in [supported chains](/docs/kb/supported-chains). Cross-chain USDC quotes are the exception: on a development instance they use real testnet USDC, not USDB, on the CCTP v2 testnets (Ethereum Sepolia, Polygon Amoy, Base Sepolia, Arbitrum Sepolia), since Circle's protocol can only move native USDC. A USDC and EURC conversion runs between `USDB` and `EURB` on `base_sepolia`, priced by the liquidity provider's sandbox.

## Related

* [Send](/docs/send): the concept overview and scope for transfers
* [Transfers](/docs/transfers): execute a transfer against this quote
* [Managed wallets](/docs/wallets): create and fund the source wallet
* [Payout quotes](/docs/payout-quotes): compare against the quote used for off-ramp payouts
* [Supported chains](/docs/kb/supported-chains): token and network support matrix
