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
| Field | Type | Required | Notes |
|---|---|---|---|
fromCurrency | string, 2–10 chars | yes | The currency to sell. You must hold an account in it. |
toCurrency | string, 2–10 chars | yes | The currency to buy. You must hold an account in it. The same code on both sides is refused. |
amount | decimal string | yes | The sell side — what you give up. Never a JSON number. No more fractional digits than fromCurrency settles at (USD: 2, USDC: 6). |
sourceAccountId | UUID | no | The 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. |
targetAccountId | UUID | no | The account to credit, in toCurrency, on the same terms. |
toAccountId | UUID | no | Where 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. |
quoteId | UUID | no | A quote for this exact conversion. The conversion then executes at that quote's rate and commission and is never re-quoted. Optional for now. |
idempotencyKey | string, up to 100 chars | no | Alternative 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.
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"
}'{
"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
| Field | Meaning |
|---|---|
from.requestedAmount | What you asked to sell, normalised: a submitted "1000.00" comes back as "1000". |
from.filledAmount | What actually sold. Equal to requestedAmount on a whole fill. null until the order settles. |
from.unfilledAmount | requestedAmount 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.amount | What the fill bought and credited to the target account. null until the order settles. |
rate.executed | The price the order actually filled at, in units of toCurrency per one unit of fromCurrency. |
rate.quoted | The price taken before the order was placed. |
rate.quotedExpiresAt | When that quote stopped being usable. |
rate.pricingModel | MARKET, 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.note | What pricingModel means for this number, in words. |
commission | The 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. |
networkFee | Always null: TCB covers any network cost of a conversion and never charges it. null does not mean the cost was zero. |
tradeOrderId | The order this conversion drove. Quote it in a support request. |
toAccountId | The account you named as toAccountId. null when you named none. |
transferId | The 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 differenttargetAccountIdbeside it is refused with409 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.transferIdnames 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:
| Status | Code | Meaning |
|---|---|---|
| 400 | CONVERSION_DESTINATION_NOT_SUPPORTED | toAccountId is not a fiat account. |
| 400 or 403 | CONVERSION_ACCOUNT_NOT_USABLE | details.side is destination: the account does not carry toCurrency (400), or is not active (403). |
| 403 | INSUFFICIENT_SCOPE | The key lacks transfer:execute. |
| 404 | CONVERSION_ACCOUNT_NOT_FOUND | details.side is destination: no such account in your tree. |
| 409 | CONVERSION_DESTINATION_CONFLICT | toAccountId is one of your accounts and targetAccountId names another. Name one. |
| 409 | SUB_CLIENT_CAPABILITY_UNAVAILABLE | The 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.
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"
}'{
"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"
}| Field | Meaning |
|---|---|
rate.quoted | The venue's price, in units of toCurrency per one unit of fromCurrency. Always MARKET. |
commission | The commission the conversion will be charged at this rate, and the currency it is taken in. null when it is charged none. |
receiveAmount | What the conversion credits at the quoted rate, net of the commission where the commission is taken from the currency you receive. |
expiresAt | When 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, withdetails.fieldsnaming 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, withdetails.quotedRateanddetails.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
- 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. - The order is placed against that quote.
- If the quote expires before execution, TCB re-quotes once. A usable re-quote is executed against. Anything else fails with
503 CONVERSION_QUOTE_EXPIREDand 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.
| Status | Meaning | What to do |
|---|---|---|
PENDING / EXECUTING_ONCHAIN | In flight | Wait or poll. |
SETTLED | Both accounts moved | Done. |
FAILED | Nothing moved | Safe to retry under a new key. |
FAILED_PENDING_COMPENSATION | An order moved money, including a partial fill, and the outcome could not be completed or established | Do not retry. |
COMPENSATED | An operator resolved it | Read 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.
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.
| Status | Code | What to do |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALID | Send a key, in one place, within the allowed characters and length. |
| 400 | CONVERSION_PAIR_INVALID | fromCurrency and toCurrency are the same. |
| 400 | INVALID_AMOUNT | amount was not a positive decimal string. |
| 400 | AMOUNT_PRECISION_EXCEEDED | Round to details.allowedDecimals and resubmit. |
| 400 | CONVERSION_AMOUNT_NOT_REPRESENTABLE | The amount cannot cross the order contract without losing precision. Adjust it. |
| 400 | INSUFFICIENT_BALANCE | The source account does not hold the amount. |
| 400 | CONVERSION_RISK_REJECTED | A trading risk rule refused it. details.reasons says which. |
| 400 | CONVERSION_CAP_EXCEEDED | The 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 403 | CONVERSION_ACCOUNT_NOT_USABLE | 400 when the named account does not consistently carry that currency; 403 when it is not active. details.side and details.currency name it. |
| 400 | CONVERSION_DESTINATION_NOT_SUPPORTED | toAccountId is not a fiat account. |
| 403 | INSUFFICIENT_SCOPE | The key lacks trade:execute, transfer:execute with toAccountId, or transaction:read on the fetch. |
| 404 | CONVERSION_ACCOUNT_NOT_FOUND | You hold no usable account on that side. details.side and details.currency name it. |
| 404 | INTENT_NOT_FOUND | No conversion with that id in your scope. |
| 409 | CONVERSION_ACCOUNT_AMBIGUOUS | You hold more than one active account in that currency. Name one — details.side says which side, details.accountIds lists the candidates. |
| 409 | CONVERSION_DESTINATION_CONFLICT | toAccountId is one of your accounts and targetAccountId names another. Name one. |
| 409 | SUB_CLIENT_CAPABILITY_UNAVAILABLE | The sub-client named by toAccountId cannot receive yet. |
| 409 | CONVERSION_PAIR_NOT_AVAILABLE | No enabled external venue can route that pair in either direction. This is a provisioning gap; retrying will not change it. Contact TCB. |
| 409 | CURRENCY_NOT_CONFIGURED | That currency is not configured. Contact TCB. |
| 404 | QUOTE_NOT_FOUND | No conversion quote with that quoteId in your scope. Request a quote. |
| 409 | QUOTE_EXPIRED | The quote is past its expiresAt. Request a new quote. |
| 409 | QUOTE_ALREADY_USED | The quote already executed a conversion. Request a new quote. |
| 409 | QUOTE_MISMATCH | The request differs from the quoted one; details.fields names what differs. Send the quoted request, or request a new quote. |
| 409 | QUOTE_TERMS_CHANGED | The 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. |
| 500 | CONVERSION_RESULT_UNREADABLE | The 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. |
| 409 | IDEMPOTENCY_KEY_REUSED | Same key, different parameters. A quoteId is one of the parameters. Use a new key. |
| 409 | IDEMPOTENT_REQUEST_IN_PROGRESS | The first call under this key is still running. Retry the same key. |
| 500 | CONVERSION_FAILED | Nothing moved. Safe to retry under a new key. |
| 500 | CONVERSION_FAILED_PENDING_COMPENSATION / CONVERSION_SETTLEMENT_NOT_RECORDED | Money moved. Do not re-key — see the callout above. |
| 503 | PRICING_UNAVAILABLE | No 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. |
| 503 | CONVERSION_QUOTE_EXPIRED | The 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.