Pilot environment: testnet, simulated yield, mocked fiat rails — read the disclosure
Guides

Conversions

Sell one currency you hold for another, into one of your own accounts or a sub-client's, and read the rate you got.

Concepts: Conversions in the money lifecycle

A conversion sells an exact amount of one currency you hold for another, between two accounts you already hold, and can end in a sub-client's account (convert into another account of your tree). It is a separate step: convert, read the rate you got, then decide whether to deposit or withdraw the proceeds. No other route converts for you.

A conversion can be fiat to fiat, fiat to crypto, crypto to fiat or crypto to crypto: the route takes any two currencies you hold an active account in.

The route acts as you, never as a sub-client, and refuses x-on-behalf-of with 400 ON_BEHALF_OF_NOT_ALLOWED_HERE.

The rate is a venue's quote, or there is no conversion

TCB prices a conversion only from an external venue that quotes independently of TCB. When no such venue can price the pair, the request is refused with 503 PRICING_UNAVAILABLE and nothing is converted; TCB never prices it from an internal table instead. Read rate.pricingModel on every response before you present the number anywhere: MARKET means a venue quoted it. SIMULATED appears only on conversions settled before 2026-09-06 and means TCB generated the price from a fixed internal table; rate.note says so in words, treat that note as binding, and never present, store or report such a rate as a market rate. Converting into a currency and straight back out is priced twice.

Convert a balance

POST /conversions · scope trade:execute (plus transfer:execute with toAccountId) · idempotency key required

FieldTypeRequiredNotes
fromCurrencystring, 2–10 charsyesThe currency to sell. You must hold an account in it.
toCurrencystring, 2–10 charsyesThe currency to buy. You must hold an account in it. The same code on both sides is refused.
amountdecimal stringyesThe sell side — what you give up. Never a JSON number. No more fractional digits than fromCurrency settles at (USD: 2, USDC: 6).
sourceAccountIdUUIDnoThe account to debit, in fromCurrency. Omitted, your single active account in it is used — see naming the right account. The account your crypto deposit address credits counts like any other.
targetAccountIdUUIDnoThe account to credit, in toCurrency, on the same terms.
toAccountIdUUIDnoWhere the converted amount ends up: one of your own fiat accounts in toCurrency, or a fiat account in toCurrency of one of your sub-clients. See convert into another account of your tree.
quoteIdUUIDnoA quote for this exact conversion. The conversion then executes at that quote's rate and commission and is never re-quoted. Optional for now.
idempotencyKeystring, up to 100 charsnoAlternative to the Idempotency-Key header. One of the two is required; sending both is allowed only if they match.

Replaying a key returns the first result with replayed: true and places no second order. A new key on a retry path places a second order. The full rule is in Conventions.

Request
curl -X POST https://api.threecommabank.com/api/partner/v1/conversions \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "Idempotency-Key: acme-conversion-2026-09-02-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "fromCurrency": "USDC",
    "toCurrency": "USD",
    "amount": "1000.00",
    "sourceAccountId": "4d80f3b6-9a17-4c25-8e93-0b6d1f7a3c58",
    "targetAccountId": "1b9d4f07-6c23-4a8e-b510-9f2c7d3e5a68"
  }'
Response 201
{
  "success": true,
  "data": {
    "conversionId": "6a4d0f38-7c15-42be-9038-4b7e1c6a2d59",
    "status": "SETTLED",
    "idempotencyKey": "acme-conversion-2026-09-02-0001",
    "replayed": false,
    "from": {
      "currency": "USDC",
      "requestedAmount": "1000",
      "filledAmount": "1000",
      "unfilledAmount": "0"
    },
    "to": { "currency": "USD", "amount": "997.30" },
    "rate": {
      "executed": "0.9998",
      "quoted": "0.9999",
      "quotedExpiresAt": "2026-09-02T10:15:58.000Z",
      "pricingModel": "MARKET",
      "note": "This rate came from a venue that prices independently of TCB."
    },
    "commission": { "amount": "2.5", "currency": "USD" },
    "networkFee": null,
    "tradeOrderId": "e5c17a03-9d42-4b68-8f31-0a7c2e6b4d95",
    "toAccountId": null,
    "transferId": null,
    "settledAt": "2026-09-02T10:15:33.000Z",
    "createdAt": "2026-09-02T10:15:28.000Z",
    "disclosure": { "version": "2026-09-06", "summary": "…", "items": ["…"] }
  },
  "timestamp": "2026-09-02T10:15:33.612Z"
}

disclosure is the object printed at the top of this page, abbreviated here. It arrives in full on the wire.

Read the result

FieldMeaning
from.requestedAmountWhat you asked to sell, normalised: a submitted "1000.00" comes back as "1000".
from.filledAmountWhat actually sold. Equal to requestedAmount on a whole fill. null until the order settles.
from.unfilledAmountrequestedAmount minus filledAmount, and "0" on a whole fill. Both figures are always present, so a partial fill cannot be mistaken for a smaller conversion you asked for.
to.amountWhat the fill bought and credited to the target account. null until the order settles.
rate.executedThe price the order actually filled at, in units of toCurrency per one unit of fromCurrency.
rate.quotedThe price taken before the order was placed.
rate.quotedExpiresAtWhen that quote stopped being usable.
rate.pricingModelMARKET, meaning a venue priced it independently of TCB — the only way a conversion is filled since 2026-09-06. SIMULATED appears only on conversions settled before that date. null until the fill establishes which venue priced it.
rate.noteWhat pricingModel means for this number, in words.
commissionThe commission this conversion was charged, as booked when it settled: amount and the currency it was taken in, one of the two currencies of the conversion. Where it is taken from the currency you receive, to.amount is already net of it. null until the conversion settles, and null on a settled conversion charged none.
networkFeeAlways null: TCB covers any network cost of a conversion and never charges it. null does not mean the cost was zero.
tradeOrderIdThe order this conversion drove. Quote it in a support request.
toAccountIdThe account you named as toAccountId. null when you named none.
transferIdThe transfer that moved the converted amount into a sub-client's account, as GET /transfers lists it. null when no move was needed, and until the conversion settles.

rate.executed is the price you actually got. TCB publishes no other rate beside it.

Convert into another account of your tree

Name toAccountId to end the conversion somewhere other than your default account in toCurrency. The key needs transfer:execute as well as trade:execute.

  • One of your own accounts. The conversion is credited to it directly, as if you had named it as targetAccountId. Naming a different targetAccountId beside it is refused with 409 CONVERSION_DESTINATION_CONFLICT.
  • A sub-client's account. The conversion is credited to your own account in toCurrency (targetAccountId, or your single active one) and the converted amount moves on to the sub-client in the same step that settles the conversion. transferId names that move. The conversion never settles without the move, and the move never posts without the settlement.

The destination must be a fiat account in toCurrency, open, and in your tree: your own, or a sub-client created directly under you. The sub-client must be able to receive. Everything is checked before anything is converted, and a refusal leaves your idempotency key usable:

StatusCodeMeaning
400CONVERSION_DESTINATION_NOT_SUPPORTEDtoAccountId is not a fiat account.
400 or 403CONVERSION_ACCOUNT_NOT_USABLEdetails.side is destination: the account does not carry toCurrency (400), or is not active (403).
403INSUFFICIENT_SCOPEThe key lacks transfer:execute.
404CONVERSION_ACCOUNT_NOT_FOUNDdetails.side is destination: no such account in your tree.
409CONVERSION_DESTINATION_CONFLICTtoAccountId is one of your accounts and targetAccountId names another. Name one.
409SUB_CLIENT_CAPABILITY_UNAVAILABLEThe sub-client cannot receive yet.

The conversion is the money call: it counts against your conversion money caps on the sell side, and the sell currency must be yours. A sub-client's account cannot be the source.

If the order fills and the move into the sub-client's account then cannot post, the conversion answers 500 CONVERSION_FAILED_PENDING_COMPENSATION. The converted amount is in your own account in toCurrency, nothing reached the sub-client, and a TCB operator resolves it. Do not re-key it — see the state ladder.

Quote a conversion first

POST /conversions/quote · scope trade:execute (plus transfer:execute with toAccountId) · no idempotency key

Quoting is optional and recommended. A quote prices the conversion you are about to make without making it: the rate a market venue quotes for it, the commission it will be charged at that rate, and what it would credit. Nothing is converted.

The quote takes the same fields as the conversion, except quoteId and idempotencyKey, and runs the same checks before anything is converted: both accounts, your money cap, a real venue quote and the trading risk check. A refusal here is the refusal the conversion would get. A quote reserves nothing: your balance and the risk check are checked again when you convert.

Request
curl -X POST https://api.threecommabank.com/api/partner/v1/conversions/quote \
  -H "Authorization: Bearer $TCB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fromCurrency": "USDC",
    "toCurrency": "USD",
    "amount": "1000.00"
  }'
Response 201
{
  "success": true,
  "data": {
    "quoteId": "5e2c9a17-8b40-4d63-a1f8-3c7e0b9d2f46",
    "from": { "currency": "USDC", "amount": "1000" },
    "toCurrency": "USD",
    "rate": {
      "quoted": "0.9998",
      "pricingModel": "MARKET",
      "note": "This rate came from a venue that prices independently of TCB."
    },
    "commission": { "amount": "2.5", "currency": "USD" },
    "receiveAmount": "997.30",
    "networkFee": null,
    "expiresAt": "2026-10-30T10:15:58.000Z",
    "disclosure": { "version": "2026-10-27", "summary": "…", "items": ["…"] }
  },
  "timestamp": "2026-10-30T10:15:28.402Z"
}
FieldMeaning
rate.quotedThe venue's price, in units of toCurrency per one unit of fromCurrency. Always MARKET.
commissionThe commission the conversion will be charged at this rate, and the currency it is taken in. null when it is charged none.
receiveAmountWhat the conversion credits at the quoted rate, net of the commission where the commission is taken from the currency you receive.
expiresAtWhen the quote stops being usable: the venue's own quote window, and never more than 60 seconds.

A quoted conversion fills at the quoted rate or better, and is charged at most the quoted commission. It is never filled at a worse rate: if the market has moved against the quote, the conversion is refused instead, with nothing converted.

To execute at the quote, name quoteId on the conversion before expiresAt. The conversion must repeat the quoted request: the same fromCurrency, toCurrency, amount (compared by value, so "1000" matches "1000.00"), sourceAccountId, targetAccountId and toAccountId. A quote executes one conversion, and a conversion that names one is never re-quoted.

A conversion naming a quote is refused, with nothing converted and the idempotency key still usable, when:

  • the quote is past expiresAt: 409 QUOTE_EXPIRED
  • the quote has already executed a conversion: 409 QUOTE_ALREADY_USED
  • the request differs from the quoted one: 409 QUOTE_MISMATCH, with details.fields naming what differs
  • the commission terms changed since the quote: 409 QUOTE_TERMS_CHANGED
  • the market rate is already below the quoted rate: 409 QUOTE_TERMS_CHANGED, with details.quotedRate and details.availableRate
  • there is no conversion quote with that id in your scope: 404 QUOTE_NOT_FOUND

In each case, request a new quote.

The price can also move in the moment between those checks and the fill. The order is then refused before it reaches the venue: nothing is converted or charged, and the answer is 409 QUOTE_TERMS_CHANGED with details.quotedRate, details.availableRate and the conversionId. By then the idempotency key and the quote are both spent, so request a new quote and convert under a new key.

Understand quoting and execution

  1. Pre-flight takes a quote from the external venues able to price the pair — never from an internal table — and checks it against your balance and the shared trading risk rules. If no venue returns a usable quote, the request is refused with 503 PRICING_UNAVAILABLE; nothing is converted and your idempotency key stays usable.
  2. The order is placed against that quote.
  3. If the quote expires before execution, TCB re-quotes once. A usable re-quote is executed against. Anything else fails with 503 CONVERSION_QUOTE_EXPIRED and no order is placed. A stale price is never filled.

A conversion that names a quote skips step 1's fresh quote: it executes against the venue quote its quote holds, and step 3's re-quote never happens. If that quote lapses in the moment between the checks and the order, the conversion fails with 503 CONVERSION_QUOTE_EXPIRED and no order is placed.

The trading risk rules apply to a conversion on this API exactly as to any other order: a shared trading risk check limits order rate, size and daily volume, scoped to your client principal — every sub-account shares one bucket. A refusal is 400 CONVERSION_RISK_REJECTED with details.reasons listing the failed checks. It happens before anything moves, and your idempotency key stays usable.

Follow the state ladder

A conversion walks the same six states as a deposit or a withdrawal. See The money lifecycle. One difference matters to your retry logic: a conversion can report that a failure moved nothing, because the order record is written before any money moves.

StatusMeaningWhat to do
PENDING / EXECUTING_ONCHAINIn flightWait or poll.
SETTLEDBoth accounts movedDone.
FAILEDNothing movedSafe to retry under a new key.
FAILED_PENDING_COMPENSATIONAn order moved money, including a partial fill, and the outcome could not be completed or establishedDo not retry.
COMPENSATEDAn operator resolved itRead the object for the outcome.

Never re-key a compensable failure

500 CONVERSION_FAILED_PENDING_COMPENSATION and 500 CONVERSION_SETTLEMENT_NOT_RECORDED both mean money moved. Retrying with a fresh idempotency key places a second order. A TCB operator resolves these, and nothing resolves them on a timer. Polling the original key will not change the answer until a person has acted. Quote the conversionId from details when you contact TCB.

Fetch a conversion

GET /conversions/{id} · scope transaction:read

Returns the conversion in its current state, including a terminal failure, which is reported as the object rather than as an error. A settled conversion answers with the same body the POST returned. It is the poll surface the errors above point at, and the fetch half of the thin webhook payloads.

Request
curl https://api.threecommabank.com/api/partner/v1/conversions/6a4d0f38-7c15-42be-9038-4b7e1c6a2d59 \
  -H "Authorization: Bearer $TCB_API_KEY"

An id that is not yours, or that belongs to a different flow, answers 404 INTENT_NOT_FOUND. There is no list route. Keep your own ids, or read them off the webhook events.

Money caps

TCB may set ceilings on your account's conversions, per sell currency — the amount you are giving up is what a ceiling bounds. Three kinds exist, all inclusive: maxTicket (one request), daily and monthly (the flow's total over the UTC calendar day or month, counted from acceptance, resetting at 00:00:00Z). They are configured, and told to you, by TCB — no ceiling applies unless one has been set for your account, and there is no API to read or change them. A conversion that would cross one is refused before any venue is asked for a quote with 400 CONVERSION_CAP_EXCEEDED: details.cap names the ceiling, details.limit its value, and details.attempted what the figure would have become. The idempotency key stays usable — resubmit the same key when the window rolls over, or convert less. Deposits and withdrawals carry their own, separate caps — money caps.

Handle the errors

Nothing has been converted on any 4xx or 503.

StatusCodeWhat to do
400IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALIDSend a key, in one place, within the allowed characters and length.
400CONVERSION_PAIR_INVALIDfromCurrency and toCurrency are the same.
400INVALID_AMOUNTamount was not a positive decimal string.
400AMOUNT_PRECISION_EXCEEDEDRound to details.allowedDecimals and resubmit.
400CONVERSION_AMOUNT_NOT_REPRESENTABLEThe amount cannot cross the order contract without losing precision. Adjust it.
400INSUFFICIENT_BALANCEThe source account does not hold the amount.
400CONVERSION_RISK_REJECTEDA trading risk rule refused it. details.reasons says which.
400CONVERSION_CAP_EXCEEDEDThe conversion would cross a money cap set for your account on the sell side. details.cap names the ceiling. Resubmit the same key when it clears, or convert less.
400 or 403CONVERSION_ACCOUNT_NOT_USABLE400 when the named account does not consistently carry that currency; 403 when it is not active. details.side and details.currency name it.
400CONVERSION_DESTINATION_NOT_SUPPORTEDtoAccountId is not a fiat account.
403INSUFFICIENT_SCOPEThe key lacks trade:execute, transfer:execute with toAccountId, or transaction:read on the fetch.
404CONVERSION_ACCOUNT_NOT_FOUNDYou hold no usable account on that side. details.side and details.currency name it.
404INTENT_NOT_FOUNDNo conversion with that id in your scope.
409CONVERSION_ACCOUNT_AMBIGUOUSYou hold more than one active account in that currency. Name one — details.side says which side, details.accountIds lists the candidates.
409CONVERSION_DESTINATION_CONFLICTtoAccountId is one of your accounts and targetAccountId names another. Name one.
409SUB_CLIENT_CAPABILITY_UNAVAILABLEThe sub-client named by toAccountId cannot receive yet.
409CONVERSION_PAIR_NOT_AVAILABLENo enabled external venue can route that pair in either direction. This is a provisioning gap; retrying will not change it. Contact TCB.
409CURRENCY_NOT_CONFIGUREDThat currency is not configured. Contact TCB.
404QUOTE_NOT_FOUNDNo conversion quote with that quoteId in your scope. Request a quote.
409QUOTE_EXPIREDThe quote is past its expiresAt. Request a new quote.
409QUOTE_ALREADY_USEDThe quote already executed a conversion. Request a new quote.
409QUOTE_MISMATCHThe request differs from the quoted one; details.fields names what differs. Send the quoted request, or request a new quote.
409QUOTE_TERMS_CHANGEDThe commission terms changed since the quote, or the market rate moved below the quoted rate (details.quotedRate, details.availableRate). Nothing converted. Request a new quote; when details.conversionId is present the key is spent too, so use a new key.
500CONVERSION_RESULT_UNREADABLEThe conversion settled, but its commission could not be read back for this response. Nothing is owed and nothing needs retrying: GET /conversions/{conversionId} or a replay of the same key returns the full result. Never re-key.
409IDEMPOTENCY_KEY_REUSEDSame key, different parameters. A quoteId is one of the parameters. Use a new key.
409IDEMPOTENT_REQUEST_IN_PROGRESSThe first call under this key is still running. Retry the same key.
500CONVERSION_FAILEDNothing moved. Safe to retry under a new key.
500CONVERSION_FAILED_PENDING_COMPENSATION / CONVERSION_SETTLEMENT_NOT_RECORDEDMoney moved. Do not re-key — see the callout above.
503PRICING_UNAVAILABLENo external venue returned a usable quote right now, and TCB does not price a conversion from an internal table instead. details.pair names the pair. Retry.
503CONVERSION_QUOTE_EXPIREDThe quote lapsed and the single re-quote produced no usable price, or the quote you named lapsed in the moment before execution. No order was placed. Retry under a new key (with a new quote, if you named one).

The full catalogue is in Errors.

Receive the events

Subscribe at POST /webhooks to conversion.settled and conversion.failed. A compensable outcome fires no event — see which webhook fires when.

The machine-readable specification for these endpoints is openapi.partner.v1.json. Where it and this page disagree, the specification is authoritative.

Pilot disclosure

On this page