FX Swaps

Swap between stablecoins of different currencies using a two-step estimate and execute flow.

Before You Start

Read the following guides before proceeding:

GuideWhy
Getting StartedPlatform overview and setup
Api BasicsRequired headers and request configuration
AuthenticationHow to obtain access tokens
OnboardingUser and wallet registration
Unified BalanceUnified tokens and balance model

Overview

FX swaps convert between stablecoins denominated in different currencies — USDC to EURC, for example.
The swap is executed by Circle, not on-chain by the user's wallet, so the flow is not a single
transaction. It has three parts:

  1. Estimate. You ask for a quote. Wirex returns a rate, an estimation_id, a deposit address, and
    an expiry.
  2. Pay. You send the source tokens to the deposit address from the user's wallet, on-chain,
    yourself. Wirex does not move the funds for you.
  3. Execute. You hand back the estimation_id and the hash of the payment transaction. Wirex
    settles the swap and returns a trade_id.

Between steps 2 and 3 the funds are in transit and the quote is expiring. Handle that window
explicitly — see Limitations.


Prerequisites

RequirementDetail
StableExchange capabilityMust be Active on the user. Check via GET /api/v1/capabilities — see Capabilities
Both tokens supportedSource and destination contract addresses must both appear in GET /api/v1/config/tokens
Funded walletThe user must hold enough of the source token to make the on-chain payment

If the capability is not Active, both endpoints fail before any quote is produced. Read
status_reason from the capabilities response to find out why — the reasons are listed in
Diagnosing a Blocked Capability.


Step 1: Estimate the Swap

POST /api/v1/fx/estimate

Quote in one direction only. Send either source_asset_amount ("swap 100.50 USDC into whatever
that buys") or destination_asset_amount ("buy me exactly 95.25 EURC"), never both and never
neither.

Request

{
  "correlation_id": "550e8400-e29b-41d4-a716-446655440000",
  "source_asset_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  "source_asset_amount": "100.50",
  "destination_asset_address": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c"
}
FieldTypeRequiredDescription
correlation_idstringNoClient-supplied tracking identifier. Generated server-side if omitted
source_asset_addressstringYesContract address of the token to swap from. Must be non-zero, supported, and a regular token — see Limitations
source_asset_amountstringConditionalDecimal amount to swap. Required unless destination_asset_amount is sent
destination_asset_addressstringYesContract address of the token to receive. Must be non-zero and supported
destination_asset_amountstringConditionalDecimal amount to receive. Required unless source_asset_amount is sent

Amounts are decimal strings, not integers in the token's smallest unit — "100.50", not
"100500000". They must parse as a positive number; zero and negative values are refused.

Response

{
  "estimation_id": "550e8400-e29b-41d4-a716-446655440000",
  "source_asset_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  "source_asset_amount": "100.50",
  "destination_asset_address": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c",
  "destination_asset_amount": "95.25",
  "trade_rate": "0.952500",
  "destination_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
  "deposit_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
  "expiration_time": "2024-01-15T10:30:00Z"
}
FieldDescription
estimation_idIdentifier for this quote. Pass to POST /api/v1/fx in step 3
source_asset_addressEcho of the requested source token
source_asset_amountAmount of source token to send, formatted to 2 decimal places
destination_asset_addressEcho of the requested destination token
destination_asset_amountAmount of destination token to be received, formatted to 2 decimal places
trade_rateExchange rate applied to this quote
destination_addressAddress to send the source tokens to
deposit_addressDeprecated. Mirrors destination_address for backward compatibility. Read destination_address
expiration_timeRFC 3339 timestamp after which the quote is no longer valid

Both amount fields are formatted to exactly two decimal places regardless of the token's own
precision. Treat them as display values. When you quoted by destination_asset_amount, the
source_asset_amount shown has been rounded for presentation — do not derive an on-chain transfer
amount from it by parsing it back at full precision.


Step 2: Pay the Deposit Address

Send source_asset_amount of the source token to destination_address from the user's wallet. This
is an ordinary on-chain ERC-20 transfer that your integration submits; there is no Wirex endpoint for
it. Keep the transaction hash — step 3 requires it.

Wait for the transaction to be mined before continuing. Submitting an unmined or reverted hash to
step 3 fails the swap.


Step 3: Execute the Swap

POST /api/v1/fx

Request

{
  "estimation_id": "550e8400-e29b-41d4-a716-446655440000",
  "payment_transaction_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
}
FieldTypeRequiredDescription
estimation_idstringYesThe estimation_id from step 1. Must be a valid UUID
payment_transaction_hashstringYesHash of the payment transaction. 0x-prefixed, exactly 66 characters

Response

{
  "trade_id": "550e8400-e29b-41d4-a716-446655440000"
}
FieldDescription
trade_idIdentifier for the executed trade

Code Examples

// Step 1: estimate
const estimate = await fetch(`${baseUrl}/api/v1/fx/estimate`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'X-User-Wallet': userEoaAddress,
    'X-Chain-Id': chainId,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    source_asset_address: sourceTokenAddress,
    source_asset_amount: '100.50',
    destination_asset_address: destinationTokenAddress
  })
}).then(r => r.json());

// Step 2: pay destination_address on-chain, then take the hash
const paymentTransactionHash = await sendTokens(
  estimate.destination_address,
  estimate.source_asset_amount
);

// Step 3: execute
const trade = await fetch(`${baseUrl}/api/v1/fx`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'X-User-Wallet': userEoaAddress,
    'X-Chain-Id': chainId,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    estimation_id: estimate.estimation_id,
    payment_transaction_hash: paymentTransactionHash
  })
}).then(r => r.json());
headers = {
    "Authorization": f"Bearer {access_token}",
    "X-User-Wallet": user_eoa_address,
    "X-Chain-Id": chain_id,
    "Content-Type": "application/json"
}

# Step 1: estimate
estimate = requests.post(
    f"{base_url}/api/v1/fx/estimate",
    headers=headers,
    json={
        "source_asset_address": source_token_address,
        "source_asset_amount": "100.50",
        "destination_asset_address": destination_token_address
    }
).json()

# Step 2: pay destination_address on-chain, then take the hash
payment_transaction_hash = send_tokens(
    estimate["destination_address"],
    estimate["source_asset_amount"]
)

# Step 3: execute
trade = requests.post(
    f"{base_url}/api/v1/fx",
    headers=headers,
    json={
        "estimation_id": estimate["estimation_id"],
        "payment_transaction_hash": payment_transaction_hash
    }
).json()
// Step 1: estimate
estimateBody, _ := json.Marshal(map[string]string{
    "source_asset_address":      sourceTokenAddress,
    "source_asset_amount":       "100.50",
    "destination_asset_address": destinationTokenAddress,
})

req, _ := http.NewRequest("POST", baseURL+"/api/v1/fx/estimate", bytes.NewReader(estimateBody))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-User-Wallet", userEoaAddress)
req.Header.Set("X-Chain-Id", chainId)
req.Header.Set("Content-Type", "application/json")

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var estimate FxEstimateResponse
json.NewDecoder(resp.Body).Decode(&estimate)

// Step 2: pay estimate.DestinationAddress on-chain, then take the hash
paymentTransactionHash := sendTokens(estimate.DestinationAddress, estimate.SourceAssetAmount)

// Step 3: execute
executeBody, _ := json.Marshal(map[string]string{
    "estimation_id":            estimate.EstimationId,
    "payment_transaction_hash": paymentTransactionHash,
})

req, _ = http.NewRequest("POST", baseURL+"/api/v1/fx", bytes.NewReader(executeBody))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-User-Wallet", userEoaAddress)
req.Header.Set("X-Chain-Id", chainId)
req.Header.Set("Content-Type", "application/json")

resp, _ = http.DefaultClient.Do(req)
defer resp.Body.Close()

var trade FxExecuteResponse
json.NewDecoder(resp.Body).Decode(&trade)

Limitations

  • The quote expires. expiration_time is a hard deadline that includes the time your on-chain
    payment takes to confirm. Budget for confirmation before accepting a quote, not after.
  • The payment leg is yours. Wirex issues the quote and settles the swap, but does not move funds
    to the deposit address. A failed or under-funded transfer leaves an estimation that will never be
    executable.
  • One direction per quote. An estimate fixes either the source or the destination amount. To
    change which side is fixed, request a new estimate.
  • Response amounts are 2-decimal display values, not full-precision figures.
  • deposit_address is deprecated. It currently carries the same value as destination_address.
    Read destination_address.
  • Both tokens must be in the catalogue. An address absent from GET /api/v1/config/tokens is
    refused at the estimate.
  • FX operates on regular tokens. A user holding the unified WUSD or WEUR must unwrap before
    funding the swap — destination_address expects the regular token named by
    source_asset_address (USDC, USDT, EURC), so the funding transfer has to unwrap and send the
    underlying token. Depositing a unified token is not matched to the estimate.
    Push-to-card does not share this constraint.

Error Handling

Validation Errors (400)

Error DetailsDescriptionResolution
amount: Either source_asset_amount or destination_asset_amount must be providedNeither amount sentSend exactly one
amount: Only one of source_asset_amount or destination_asset_amount can be providedBoth amounts sentSend exactly one
source_asset_address: Source asset address must be a valid non-zero contract addressMissing or zero addressSend a valid contract address
destination_asset_address: Destination asset address must be a valid non-zero contract addressMissing or zero addressSend a valid contract address
source_asset_amount: Amount must be a positive decimal stringNon-numeric, zero or negativeSend a positive decimal string
destination_asset_amount: Amount must be a positive decimal stringNon-numeric, zero or negativeSend a positive decimal string
source_asset_address: Source token not found or not supportedToken is not in the catalogueCheck GET /api/v1/config/tokens
destination_asset_address: Destination token not found or not supportedToken is not in the catalogueCheck GET /api/v1/config/tokens
estimation_id: Estimation ID is requiredField empty on executeSend the estimation_id from the estimate
estimation_id: Estimation ID must be a valid UUIDMalformed identifierSend the value verbatim from the estimate
payment_transaction_hash: Transaction hash must be 0x-prefixedMissing 0x prefixPrefix the hash
payment_transaction_hash: Transaction hash must be 66 characters (0x + 64 hex characters)Wrong lengthSend the full 32-byte hash
payment_transaction_hash: Transaction hash contains invalid hex charactersNon-hex characters presentSend a hex-encoded hash
user: Requested user account not found or does not belong to youIdentity headers do not resolve to a userCheck X-User-Wallet matches the token

Example Error Response

{
  "error_reason": "ErrorInvalidField",
  "error_description": "Request failed validation",
  "error_category": {
    "category": "CategoryValidationFailure",
    "http_status_code": 400
  },
  "error_details": [
    { "key": "amount", "details": "Only one of source_asset_amount or destination_asset_amount can be provided" }
  ]
}

Capability Errors

A user without an Active StableExchange capability is refused on both endpoints. The failure
surfaces as a capability error rather than a field error — resolve it through
Capabilities, not by changing the request.


Did this page help you?