Overview

OTC Exchanges let you convert between EUR and USDC in both directions, settling against your own Balansas resources:
  • Buy (EUR → USDC) — pay from one of your EUR fiat accounts and receive into one of your custodial wallets.
  • Sell (USDC → EUR) — pay from one of your wallets and receive into one of your EUR fiat accounts.
Direction is chosen with the side parameter ("buy" or "sell") on both POST /otc-exchanges/quote and POST /otc-exchanges. When side is omitted it defaults to "buy".
Every order is manually approved by Balansas. The rate on a quote is indicative — the final executed rate is set at operator approval against live market pricing. All responses use internal local UUIDs only; vendor identifiers stay server-side.
This is a separate resource from the legacy /exchanges (Rail.io) endpoint. OTC orders live under the /otc-exchanges prefix and use the dedicated scopes below.

Authentication & scopes

ScopeGrants
otc-exchanges:readall GET routes (list, detail, /accounts, /wallets)
otc-exchanges:writeall POST routes (quote, confirm, cancel)
  • Auth is a session JWT or an x-api-key. Pay-from and receive-into resources are validated server-side against your own customer — you can only transact with resources you own.
  • POST /otc-exchanges (confirm) requires an Idempotency-Key header (UUID v4). A request without it is rejected with 400 IDEMPOTENCY_KEY_REQUIRED; repeating a call with the same key replays the original stored response and never acts twice.
  • The selectors serve both directions: /otc-exchanges/accounts is the buy source and the sell destination; /otc-exchanges/wallets is the buy destination and the sell source.

The flow

1

List your eligible resources

GET /otc-exchanges/accounts (your active EUR fiat accounts) and GET /otc-exchanges/wallets (your non-treasury wallets with a USDC address) populate the source/destination selectors.
2

Request an indicative quote

POST /otc-exchanges/quote with the side, the source/destination ids, and the source amount. You get a short-lived quoteId (it is the order id) and an indicative rate.
3

Confirm the quote into an order

POST /otc-exchanges with the quoteId and an Idempotency-Key header. A buy debits your EUR to the Balansas treasury immediately; a sell moves no funds yet.
4

Track to completion

Balansas approves the order; the final rate is set at approval. Poll GET /otc-exchanges/{id} or your order list for status.

List eligible accounts (Pay-from / Receive-into EUR)

GET /otc-exchanges/accounts · scope otc-exchanges:read Your own active EUR fiat accounts — the source for a buy and the destination for a sell.
Response
{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "currency": "EUR",
      "isMaster": true,
      "label": "My EUR Business Account",
      "balance": "10000.50"
    }
  ]
}

List eligible wallets (Receive-into / Pay-from USDC)

GET /otc-exchanges/wallets · scope otc-exchanges:read Your own non-treasury wallets that have a USDC address — the destination for a buy and the source for a sell. Each wallet includes its chosen USDC network.
Response
{
  "data": [
    {
      "id": "660e8400-e29b-41d4-a716-446655440001",
      "label": "Operations wallet",
      "network": "polygon",
      "usdcAddress": "0xabcdef0123456789abcdef0123456789abcdef01"
    }
  ]
}

Wallet balance

GET /otc-exchanges/wallets/{id}/balance · scope otc-exchanges:read Returns the live USDC balance for one of your wallets on its chosen network. usdcBalance is a decimal string with 6 decimal places.
Response
{
  "data": {
    "walletId": "660e8400-e29b-41d4-a716-446655440001",
    "network": "polygon",
    "usdcBalance": "1250.000000"
  }
}
StatusCodeMeaning
404WALLET_NOT_FOUNDThe wallet is not yours or does not exist.
502BALANCE_UNAVAILABLEThe live balance could not be fetched from the upstream provider.

Request a quote

POST /otc-exchanges/quote · scope otc-exchanges:write Creates a quoted order and returns a short-lived, indicative quote. The returned quoteId is the order id — pass it to the confirm step.

Buy (EUR → USDC)

side defaults to "buy" when omitted.
curl -X POST https://stagingapi.balansas.com/functions/v1/customer-api/otc-exchanges/quote \
  -H "x-api-key: sk_test_xxxxxxxxxxxxxxxxxxxx" \
  -H "X-CSRF-Token: <64-char-hex>" \
  -H "Content-Type: application/json" \
  -d '{
    "side": "buy",
    "sourceFrAccountId": "550e8400-e29b-41d4-a716-446655440000",
    "destinationUtilaWalletId": "660e8400-e29b-41d4-a716-446655440001",
    "sourceAmountEur": "100.00"
  }'
Response (200)
{
  "data": {
    "quoteId": "770e8400-e29b-41d4-a716-446655440002",
    "rate": "1.05",
    "destinationAmount": "105.00",
    "sourceAmountEur": "100.00",
    "sourceCurrency": "EUR",
    "destinationCurrency": "USDC",
    "ttlExpiresAt": "2026-05-30T00:01:00.000Z",
    "ttlSeconds": 60,
    "indicative": true
  }
}
Buy ownership errors return 422 with a clear code: SOURCE_NOT_FOUND, SOURCE_WRONG_CURRENCY, SOURCE_INACTIVE, DESTINATION_NOT_FOUND, or DESTINATION_NO_USDC_ADDRESS.

Sell (USDC → EUR)

Pay from a wallet, receive into a EUR account. side is required.
Request body
{
  "side": "sell",
  "sourceUtilaWalletId": "660e8400-e29b-41d4-a716-446655440001",
  "destinationFrAccountId": "550e8400-e29b-41d4-a716-446655440000",
  "sourceAmountUsdc": "100.000000"
}
Response (200)
{
  "data": {
    "quoteId": "880e8400-e29b-41d4-a716-446655440003",
    "rate": "0.95",
    "destinationAmount": "95.00",
    "sourceAmountUsdc": "100.000000",
    "sourceCurrency": "USDC",
    "destinationCurrency": "EUR",
    "ttlExpiresAt": "2026-05-30T00:01:00.000Z",
    "ttlSeconds": 60,
    "indicative": true
  }
}
Sell ownership errors return 422 with a clear code: SOURCE_WALLET_NOT_FOUND, SOURCE_WALLET_NO_USDC, SOURCE_WALLET_INSUFFICIENT, DESTINATION_ACCOUNT_NOT_FOUND, DESTINATION_ACCOUNT_WRONG_CURRENCY, or DESTINATION_ACCOUNT_INACTIVE. If the live wallet balance can’t be fetched, the quote returns 502 QUOTE_UNAVAILABLE.

Confirm a quote into an order

POST /otc-exchanges · scope otc-exchanges:write
Required header: Idempotency-Key (UUID v4). A request without it returns 400 IDEMPOTENCY_KEY_REQUIRED; a repeat with the same key replays the original response and does not act twice. Generate a fresh key per confirm.
Pass the same side used on the quote (defaults to "buy").

Buy

Request body
{
  "side": "buy",
  "quoteId": "770e8400-e29b-41d4-a716-446655440002"
}
The EUR is debited to the Balansas treasury and the order is submitted for manual approval.
Response (201)
{
  "data": {
    "orderId": "770e8400-e29b-41d4-a716-446655440002",
    "status": "debit_pending"
  }
}

Sell

Request body
{
  "side": "sell",
  "quoteId": "880e8400-e29b-41d4-a716-446655440003"
}
No funds move at confirm — the order goes straight to pending_approval; the on-chain USDC debit happens at operator approval.
Response (201)
{
  "data": {
    "orderId": "880e8400-e29b-41d4-a716-446655440003",
    "status": "pending_approval"
  }
}
A quote that has expired returns 400 QUOTE_EXPIRED; an order you don’t own returns 404 ORDER_NOT_FOUND.

List your orders

GET /otc-exchanges · scope otc-exchanges:read — newest first, paginated via ?page and ?limit.
Response
{
  "data": [
    {
      "id": "770e8400-e29b-41d4-a716-446655440002",
      "status": "pending_approval",
      "sourceCurrency": "EUR",
      "sourceAmount": "100.00",
      "destinationCurrency": "USDC",
      "destinationAmount": "105.00",
      "rate": "1.05",
      "createdAt": "2026-05-30T00:00:00.000Z"
    }
  ],
  "meta": { "pagination": { "page": 1, "limit": 50, "total": 1 } }
}

Get a single order

GET /otc-exchanges/{id} · scope otc-exchanges:read — returns 404 for an order owned by another customer.
Response
{
  "data": {
    "id": "770e8400-e29b-41d4-a716-446655440002",
    "status": "pending_approval",
    "sourceCurrency": "EUR",
    "sourceAmount": "100.00",
    "destinationCurrency": "USDC",
    "destinationAmount": "105.00",
    "rate": "1.05",
    "createdAt": "2026-05-30T00:00:00.000Z"
  }
}

Cancel an order (pre-debit only)

POST /otc-exchanges/{id}/cancel · scope otc-exchanges:write
Response (200)
{
  "data": {
    "orderId": "770e8400-e29b-41d4-a716-446655440002",
    "status": "cancelled"
  }
}
Cancel works the same for both directions: only quoted/requoted (truly pre-execution) orders can be cancelled here. Once an order has moved on (debit_pending or pending_approval and beyond) the endpoint returns 409 NOT_CANCELLABLE — for a buy your EUR has already moved, and for a sell the order is already queued for operator approval, so unwinding is handled by Balansas operations (operator reject), not a customer self-cancel.
Track an order through to settlement via the order list / detail above or with Webhooks. Because the final rate is set at approval, the rate and destinationAmount on a completed order may differ from the indicative quote.