A quote asks every eligible source what it would give for your user’s trade and returns the best answer, with the source that offered it and the next best sources. Requesting a quote has no side effects: nothing is created at any source until you create an order from it.
Requesting a quote
Section titled “Requesting a quote”POST /quote takes the pair, the amount and the rate type, and answers with one
quote: the best source for the request under your filters, priced by the same engine
and ranked by the same rule as the Kotova X app. It needs your API key and has a weight
of 1. The answer carries a single-use quote_id, which you send to
POST /orders to create the order at that source,
or at one of the quote’s fallback sources when that source cannot
take it.
curl https://api.kotova.io/x/v1/quote \ -H "Authorization: Bearer $KOTOVA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": {"currency": "BTC", "network": "btc"}, "to": {"currency": "USDT", "network": "trx"}, "amount": "0.05", "amount_type": "from", "rate_type": "variable", "end_user_ip": "93.184.216.34", "lang": "en" }'const response = await fetch("https://api.kotova.io/x/v1/quote", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOTOVA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: { currency: "BTC", network: "btc" }, to: { currency: "USDT", network: "trx" }, amount: "0.05", amount_type: "from", rate_type: "variable", end_user_ip: "93.184.216.34", // your user's public IP address lang: "en", }),});const quote = await response.json();if (!response.ok) throw new Error(`${quote.error.code}: ${quote.error.message}`);console.log(quote.quote_id, quote.to.amount, quote.source.name);import osimport requests
response = requests.post( "https://api.kotova.io/x/v1/quote", headers={"Authorization": "Bearer " + os.environ["KOTOVA_API_KEY"]}, json={ "from": {"currency": "BTC", "network": "btc"}, "to": {"currency": "USDT", "network": "trx"}, "amount": "0.05", "amount_type": "from", "rate_type": "variable", "end_user_ip": "93.184.216.34", # your user's public IP address "lang": "en", }, timeout=30,)quote = response.json()if not response.ok: raise RuntimeError(f"{quote['error']['code']}: {quote['error']['message']}")print(quote["quote_id"], quote["to"]["amount"], quote["source"]["name"])93.184.216.34 stands in for your user’s address. Send the real one, or leave the
field out (see Your user’s IP address).
Request
Section titled “Request”The body is JSON (Content-Type: application/json). Fields the API does not know are
refused with a 400 invalid_request (details.reason: unknown_field) rather than
ignored, so a misspelt field never goes unnoticed. The endpoint takes no query
parameters.
| Field | Required | Rules |
|---|---|---|
from |
Yes | What your user sends: an object with exactly currency and network, as GET /currencies lists them. Case does not matter. |
to |
Yes | What your user receives, in the same form. It must differ from from (422 same_currency). |
amount |
Yes | A decimal string greater than zero, such as "0.05": no exponent, no sign, at most 16 integer digits, and at most min(network decimals, 12) fractional digits on the side it refers to. Trailing zeros do not count. More fractional digits are a 400 with details.reason: too_many_decimals and details.max_fraction_digits. Send a string: a JSON number is accepted only when it prints as a plain decimal. |
amount_type |
No | Which side amount refers to: from (the amount sent, the default) or to (the amount to receive). See Quoting an exact amount received. |
rate_type |
Yes | fixed or variable. There is no default. See Fixed and variable rates. |
exclude_sources |
No | Source slugs not to use, from GET /sources: at most 20, case-insensitive, duplicates removed. Slugs the API does not know are ignored with an unknown_source warning. |
freeze_protection |
No | true asks only the sources that cannot freeze funds (freeze_protection: true in GET /sources). Default false. |
can_provide_refund_address |
No | false skips the sources that need a refund address when the order is created. Default true. |
end_user_ip |
No | Your user’s public IPv4 or IPv6 address, never your server’s. See Your user’s IP address. |
lang |
No | The language of the source notice: en (the default), de, it, es, fr or ru. A region suffix such as de-AT is accepted. Any other value falls back to en with an unsupported_lang warning instead of an error. |
freeze_protection is a filter on this one request. DEX-only mode in the Kotova X app
is a separate setting, stored on the device of whoever uses the app, and has no effect
on the API. See DEX-only mode.
Response
Section titled “Response”A quote answers 200 with the quote object. The source block, with its notice, is the
one your user must see before you create an order from this quote, together with the
notices in fallback_sources: see
Display requirements.
{ "quote_id": "q_5mJc2Vq9XkT3nR8bYw1LdP", "created_at": "2026-11-10T10:15:04Z", "expires_at": "2026-11-10T10:17:03Z", "rate_type": "variable", "amount_type": "from", "from": { "currency": "BTC", "network": "btc", "amount": "0.05" }, "to": { "currency": "USDT", "network": "trx", "amount": "3389.8" }, "rate": "67796", "limits": { "min_from_amount": "0.00029412", "max_from_amount": "0.73529411" }, "estimated_duration_seconds": 1500, "requirements": { "refund_address": "optional", "to_memo": "not_supported", "refund_memo": "not_supported" }, "filters": { "exclude_sources": [], "freeze_protection": false, "can_provide_refund_address": true, "end_user_ip_provided": true }, "provider": { "name": "Kotova X", "icon": { "url": "https://cdn.kotova.io/images/brand/kotova-x/icon.svg", "fallback_url": "https://assets.kotova.io/images/brand/kotova-x/icon.svg" }, "icon_png": { "url": "https://cdn.kotova.io/images/brand/kotova-x/icon-256.png", "fallback_url": "https://assets.kotova.io/images/brand/kotova-x/icon-256.png" }, "website_url": "https://app.kotova.io" }, "source": { "slug": "sideshift", "name": "SideShift", "kind": "centralized", "icon": { "url": "https://cdn.kotova.io/images/counterparties/cp_icon_sideshift.svg", "fallback_url": "https://assets.kotova.io/images/counterparties/cp_icon_sideshift.svg" }, "terms_url": "https://sideshift.ai/legal", "notice": { "text": "The exchange service is provided by SideShift. Creating an order confirms your agreement with the SideShift rules.", "link_text": "SideShift rules", "link_url": "https://sideshift.ai/legal", "lang": "en" }, "refund_address_forwarding": "at_creation" }, "fallback_sources": [ { "source": { "slug": "changenow", "name": "ChangeNOW", "kind": "centralized", "icon": { "url": "https://cdn.kotova.io/images/counterparties/cp_icon_changenow.svg", "fallback_url": "https://assets.kotova.io/images/counterparties/cp_icon_changenow.svg" }, "terms_url": "https://changenow.io/terms-of-use", "notice": { "text": "The exchange service is provided by ChangeNOW. Creating an order confirms your agreement with the ChangeNOW rules.", "link_text": "ChangeNOW rules", "link_url": "https://changenow.io/terms-of-use", "lang": "en" }, "refund_address_forwarding": "at_creation" }, "from": { "amount": "0.05" }, "to": { "amount": "3376.2" }, "requirements": { "refund_address": "optional" } }, { "source": { "slug": "changelly", "name": "Changelly", "kind": "centralized", "icon": { "url": "https://cdn.kotova.io/images/counterparties/cp_icon_changelly.svg", "fallback_url": "https://assets.kotova.io/images/counterparties/cp_icon_changelly.svg" }, "terms_url": "https://changelly.com/terms-of-use", "notice": { "text": "The exchange service is provided by Changelly. Creating an order confirms your agreement with the Changelly rules.", "link_text": "Changelly rules", "link_url": "https://changelly.com/terms-of-use", "lang": "en" }, "refund_address_forwarding": "at_creation" }, "from": { "amount": "0.05" }, "to": { "amount": "3371.45" }, "requirements": { "refund_address": "optional" } } ], "warnings": []}The response also carries X-Kotova-Quote-Cache (see
Caching and the fan-out allowance) and the rate
limit headers described in Rate limits.
| Field | What it is |
|---|---|
quote_id |
The quote’s id: q_ followed by 22 letters and digits, case-sensitive. Single use: send it to POST /orders before expires_at. |
created_at |
When the quote was made. |
expires_at |
When the quote stops being usable: 60 seconds (fixed) or 120 seconds (variable) after the source answered. See Freshness and expiry. |
rate_type, amount_type |
As requested. |
from |
currency, network and amount: what your user sends, rounded up. |
to |
currency, network and amount: what your user receives, rounded down. |
rate |
to.amount divided by from.amount, to 12 significant digits, truncated. Kotova computes it from the two amounts; it is never a source’s own rate figure. |
limits |
min_from_amount and max_from_amount: the quoted source’s bounds, in units of from. Either is null when the source reports none; a null maximum means no upper limit. |
estimated_duration_seconds |
A typical time from deposit to payout for this source and route, or null when there is no estimate. It is an estimate, not a commitment. |
requirements.refund_address |
required: POST /orders must send a refund_address on the from network. optional: it may, and we recommend it: where the source takes it with the order, it goes to the source then, so a refund needs no extra step, and fallback sources that need one stay eligible (see Refund addresses). This is the rule of the quote’s source; each fallback source states its own. |
requirements.to_memo |
optional when the to network supports a memo or destination tag, not_supported when it does not. |
requirements.refund_memo |
The same for the from network, where a refund would go. When it is optional, send refund_memo with the refund address if that address needs one. |
filters |
The filters as applied: exclude_sources (lower-cased, duplicates removed), freeze_protection, can_provide_refund_address and end_user_ip_provided. |
provider |
Kotova X, the provider of the service: name (always Kotova X), icon, icon_png and website_url. |
source |
The quote’s source, the one every other top-level field describes and an order from this quote is placed at first (see fallback_sources): slug, name, kind (centralized, decentralized or internal, extensible), icon (null when its logo may not be shown), terms_url, refund_address_forwarding (what an order’s refund address is used for at this source: at_creation, when_needed or none, extensible; see Refund addresses) and notice (text, link_text, link_url, lang). |
fallback_sources |
The next best sources, up to two, best first: where the order is placed when source cannot take it. Each has source (the same block, with its refund_address_forwarding and notice), from.amount, to.amount and requirements.refund_address. An empty array when there are none. See Fallback sources. |
warnings |
Non-fatal notes, usually empty. See below. |
Ignore response fields you do not know: new fields can be added at any time.
A quote can carry these warnings. Their code is an extensible list, so tolerate codes
you do not know.
code |
Extra fields | Meaning |
|---|---|---|
unknown_source |
field, sources |
exclude_sources named slugs the API does not know. They were ignored. |
deprecated_alias |
field, given, canonical |
An old spelling of a currency or network was used. The quote uses canonical; switch to it. |
unsupported_lang |
field, supported |
lang was not one of the six languages, so the notice is in English. |
end_user_ip_missing |
sources |
These sources need end_user_ip and were skipped because it was not sent. Sending it may give a better rate. |
sources_temporarily_skipped |
sources |
These sources could not be asked right now: a source is paused, or your account used up its share of that source’s quotes for this minute. A later quote may find a better rate. |
Try itPOST/quoteAPI key · weight 1
Fixed and variable rates
Section titled “Fixed and variable rates”rate_type is required, so the choice is always explicit.
fixed: the source fixes the rate when the order is created, for its deposit window. The order’sfrom.amountandto.amountare binding as long as your user sendsdeposit.amountbefore the order’sexpires_at. A deposit of a different amount, or one that arrives late, can make the orderaction_requiredinstead.variable: the rate is set when the deposit arrives. The amounts in the quote and in the order are indicative, and the order’sto.amount_sentsays what was actually paid out.
Not every source offers both rate types (rate_types in
GET /sources). A source that does not offer the requested one is
skipped. A fixed-rate quote is valid for 60 seconds, a variable-rate quote for 120.
Quoting an exact amount received
Section titled “Quoting an exact amount received”With "amount_type": "to", amount is what your user wants to receive, and its
fractional digits are checked against the to network. Each source is asked what your
user must send for it, and the best quote is the one with the smallest from.amount.
- The quote’s
to.amountis the source’s answer, rounded down, and can differ slightly from the amount you asked for. Show the quote’s amounts, not your request. limitsstay in units offrom.- When the order is created, a source that accepts a receive amount gets the receive
amount; the others get the quote’s
from.amount. Either way, the order’s own amounts are the ones that count. - A guard that refuses an order below a minimum amount received is not available yet.
What a quote fixes, and what it does not
Section titled “What a quote fixes, and what it does not”A quote fixes the sources and the pair. An order from it is placed at the quote’s
source or, when that source cannot take it, at one of its fallback_sources (unless
the order is sent with retry_next_best_source: false), and never at a source the
quote does not name. It is placed for the quote’s currencies, networks, amount,
amount_type and rate_type, under the quote’s filters. POST /orders refuses those
fields next to a quote_id.
A quote does not lock a rate, and nothing is reserved at the source. Sources price the
order again when it is created, fixed rates included. The amounts in the
201 response of POST /orders are the ones that count, and
they can differ from the quote’s. Show your user the order’s amounts and its source
before they send anything. If the amounts are not acceptable, your user can simply not
send the deposit: the order then expires, and nothing is owed.
Your user’s IP address
Section titled “Your user’s IP address”Some sources price each trade for the person making it, and need that person’s IP
address to quote at all. requires_end_user_ip in GET /sources says
which. Your key lives on your server, so the API cannot see your user’s address: pass it
in end_user_ip.
- Send your user’s public address, taken from their connection to your service or from a header your own proxy sets. Never send your server’s address.
- Private, shared (CGNAT), loopback, documentation and other special ranges are refused
with a 400
invalid_request,details.reason: not_public_unicast. The documentation ranges are refused too, so test with a real public address or leave the field out. - Without
end_user_ip, the sources that need it are skipped. The quote then carries anend_user_ip_missingwarning naming them, andfilters.end_user_ip_providedisfalse. If only such sources could serve the request, the answer is 422no_source_availablewith the reasonend_user_ip_required. - The address is passed to the sources that need it, for the quote and for an order created from it.
How the source is chosen
Section titled “How the source is chosen”Every source that serves the pair and passes your filters is asked. Only answers inside the answering source’s own limits compete.
- With
"amount_type": "from", the best quote is the one with the largestto.amount. - With
"amount_type": "to", it is the one with the smallestfrom.amount. - Amounts are compared as exact decimals. A tie goes to the source that comes first in
display_order(inGET /sources), then to the name in alphabetical order. - What Kotova earns on an order never weighs.
The best source is returned as source, and the next ones as fallback_sources. The
rule is the one the Kotova X app uses: see
Ranking parameters and weighting.
Fallback sources
Section titled “Fallback sources”fallback_sources lists the next best sources of the same ranking after source: up
to two, best first. They passed the same filters and answered inside their own limits.
A source whose amounts round to zero is left out, and the list is empty when no other
source qualifies.
| Field | What it is |
|---|---|
source |
The fallback source: the same block as the quote’s source, with its refund_address_forwarding and its notice. |
from.amount |
What your user sends if this source takes the order, rounded up. |
to.amount |
What your user receives if this source takes the order, rounded down. |
requirements.refund_address |
required: this source is tried only when the order sends a refund_address, and skipped without one. optional: it is tried either way. Sending a refund address keeps every fallback source eligible. |
By default, POST /orders tries these sources in this order when the quote’s source
cannot take the order, and answers with the order of the first one that takes it. That
order’s source and amounts are the fallback source’s, not the quote’s. Show the
notices of the fallback sources before your user confirms as well, by the same rules
as the quote’s notice; they may follow it, also in an expandable section directly next
to it. Or send retry_next_best_source: false with the order to use the quote’s
source only. See
Next best source and
Display requirements.
The top-level fields of the quote, such as rate, limits, requirements and
estimated_duration_seconds, describe the quote’s source only.
Rounding
Section titled “Rounding”Amounts are decimal strings with no exponent and no trailing zeros, and at most min(network decimals, 12) fractional digits.
- What your user sends is rounded up:
from.amountandlimits.min_from_amount. - What your user receives is rounded down:
to.amount.limits.max_from_amountis rounded down too. rateis computed from the rounded amounts and truncated to 12 significant digits. Show the amounts themselves rather than recomputing them fromrate.
Freshness and expiry
Section titled “Freshness and expiry”A quote is valid until its expires_at: 60 seconds for a fixed rate and 120 seconds
for a variable one, counted from the moment the source answered. A quote served from
the cache was answered earlier, so it has less time left. Always read expires_at
rather than assuming the full period.
A quote is single use. The first POST /orders with its quote_id claims it, and every
later one answers 409 quote_already_used, with the
order’s id in details.order_id once the order exists.
- After
expires_at,POST /ordersanswers 410quote_expired. Shortly afterwards the quote is deleted, and its id then answers 404quote_not_found, as does a quote of another account. - When an order could not be created and nothing exists at any source (a validation error, an address error, an order cap, sources that refused the order), the quote is released and can be used again until it expires.
- When a source may have created the order without confirming it, the quote stays used. See When no source creates the order.
If your user takes longer than the quote’s validity to decide, request a new quote just before they confirm, and show its amounts and its source notices.
Caching and the fan-out allowance
Section titled “Caching and the fan-out allowance”The API shares the Kotova X app’s quote cache. Two requests for the same currencies and
networks, the exact same amount, the same amount_type and the same rate_type within
the cache lifetime (30 seconds for a fixed rate, 60 seconds for a variable one) are
answered from one round of source answers. The response header X-Kotova-Quote-Cache
is hit when the cache answered and miss when the sources were asked for this
request. Some sources quote per end user and are not served from the cache.
A quote that has to ask a source live counts against your account’s fan-out allowance
as well as its budget: max(30, limit / 8) such quotes per minute, per account, where
limit is your account’s rate limit. That is a cache miss, and also a cached quote
that still asks a source live, such as a source that prices per end user when you send
end_user_ip. A round in which every source that would be asked live is over your
account’s share of that source’s quotes for the minute is skipped and not charged. Past it, the answer is 429
rate_limited with details.reason: fanout and a
Retry-After header. A quote refused this way costs nothing. Quote the amounts your
user actually chose: varying an amount slightly on every request misses the cache each
time. See Rate limits.
When no source can quote
Section titled “When no source can quote”The request is checked in this order, and the first problem found is the answer.
- The request itself. A malformed body is a 400
invalid_request, withdetails.fieldanddetails.reason. - The currencies. A currency and network that is not in the catalogue is a 422
unknown_currency; one that cannot be used in that direction right now is a 422currency_unavailable. Both name the side indetails.side. Lightning is not available yet as a receive network. The same currency and network on both sides is a 422same_currency. Only then is the number of decimal places inamountchecked against its network (400,too_many_decimals). - The pair. When no source offers the pair at all, the answer is a 422
pair_unavailable, before any source is asked. - The sources. Every eligible source is asked. A source that times out, fails, is
rate limited, paused or over its budget is skipped, and a request never fails while
another source answers. When no source gives a usable quote, the answer is the first
of these that applies:
amount_below_min(422): the amount is below the minimum of the sources that could otherwise serve it. This is also the answer when the best quote rounds to zero at the currency’s precision.amount_above_max(422): the amount is above their maximum.sources_unavailable(503): sources could not be reached right now. Retry after theRetry-Afterheader’s 10 seconds (details.retry_after_seconds).pair_unavailable(422): every source said it does not serve the pair.no_source_available(422): no source is left for this request.
amount_below_min and amount_above_max carry the bounds in units of from, so you
can show your user what is possible:
{ "error": { "code": "amount_below_min", "message": "The amount is below the minimum of every available source.", "request_id": "req_8fK2mQ9xLp3vTz7nWc1bYd4h", "details": { "min_from_amount": "0.00029412", "max_from_amount": "0.73529411", "currency": "BTC", "network": "btc" }, "doc_url": "https://docs.kotova.io/x/api/errors#amount_below_min" }}min_from_amount is the smallest minimum, rounded up. max_from_amount is the largest
maximum, rounded down, and null when a source has no maximum.
no_source_available lists, in details.reasons, the sources your request’s filters
left out, grouped by reason. The list can be empty when the remaining sources declined
for reasons of their own.
reason |
The listed sources were left out because |
|---|---|
excluded_by_request |
they are in exclude_sources. |
freeze_protection |
freeze_protection is true and they can freeze funds. |
rate_type_unsupported |
they do not offer the requested rate_type. |
end_user_ip_required |
they need end_user_ip, and the request had none. |
refund_address_required |
they need a refund address, and can_provide_refund_address is false. |
create_budget_exhausted |
they cannot take more orders through the API at the moment. |
A quote can also be refused by your account’s limits: 429
rate_limited, for the per-account budget or the fan-out
allowance above. A 503 service_unavailable means
the API itself cannot answer right now. Every error, with what to do about it, is on
the Errors page. When you write to api@kotova.io about a quote, include
the response’s X-Request-Id.