Every error response has the same shape and carries a stable code. Branch on the code, not on the message: the code is part of the contract, the wording of the message is not.
Error format
Section titled “Error format”Every error the API answers (HTTP status 400 or above) has this body:
{ "error": { "code": "quote_expired", "message": "This quote has expired. Request a new quote.", "request_id": "req_8fK2mQ9xLp3vTz7nWc1bYd4h", "details": { "quote_id": "q_5mJc2Vq9XkT3nR8bYw1LdP", "expires_at": "2026-11-10T10:17:03Z" }, "doc_url": "https://docs.kotova.io/x/api/errors#quote_expired" }}| Field | What it holds |
|---|---|
code |
The error code, one of those listed on this page. Stable within v1. |
message |
An explanation for people. It may change at any time. |
request_id |
The request’s id, also sent as the X-Request-Id header. Quote it to support. |
details |
Machine-readable specifics, for the codes that have any. Absent otherwise. |
doc_url |
The section of this page that documents the code. |
New codes may be added within v1. When you meet a code you do not know, act on the
HTTP status. A successful (2xx) answer can carry non-fatal notes instead, in
warnings; see Warnings.
HTTP statuses
Section titled “HTTP statuses”Retrying without duplicates
Section titled “Retrying without duplicates”Most 4xx errors mean the request itself has to change: sending it again unchanged gives the same answer. Retry only these, and only as described:
| Code | Retry |
|---|---|
rate_limited, order_cap_reached |
After the seconds in Retry-After. |
idempotency_in_progress |
After Retry-After (2 seconds), with the same Idempotency-Key and body. |
sources_unavailable |
After Retry-After (10 seconds). |
service_unavailable |
After Retry-After when it is sent, otherwise after a growing pause. |
source_unavailable |
Request a new quote, then create the order from the new quote_id. |
source_error with details.retryable: true |
Request a new quote, then create the order from it. |
internal_error |
See internal_error. |
POST /orders is the one request where a retry could do harm, by creating a second
order. Send an Idempotency-Key header with every order request (8 to 64 characters
of A-Z a-z 0-9 _ - : .; a UUID fits) and keep it with your own order. After a
timeout, a dropped connection or a 500 answer, send the request again with the same
key and the same body:
- if the first request created the order, the retry answers
201with that order andIdempotent-Replayed: true, and costs 1 unit; - if the first request is still running, the retry answers
409idempotency_in_progress; - if the first request failed without creating anything, the retry runs as a new request.
Keys are kept for 24 hours, per account. Use a new key for every new order, and for
an order you send with a new quote_id after requesting a new quote: its body is no
longer the same.
Error codes
Section titled “Error codes”invalid_request
Section titled “invalid_request”HTTP 400. The request is malformed. details.field names the problem: a body
field as a dot path (from.currency), a query parameter, or a header such as
Idempotency-Key. details.reason says what is wrong:
details.reason |
Meaning |
|---|---|
required |
The field is missing, or the body is empty. |
invalid_type |
The value has the wrong JSON type, or the body is not a JSON object. |
invalid_format |
The value does not have the expected form, such as a quote id, an order id or an Idempotency-Key. |
invalid_value |
The value is not one of those allowed; details.allowed lists them when the list is short. |
unknown_field |
The body has a field the endpoint does not take. Check the spelling. |
unknown_parameter |
A query parameter the endpoint does not take, or an empty query segment such as ?& on a keyless endpoint. |
duplicate_parameter |
A query parameter is given more than once. |
too_long |
An address or memo is longer than allowed. |
too_many |
exclude_sources lists more than 20 sources. |
too_many_decimals |
The amount has more decimal places than the currency allows on that network; details.max_fraction_digits gives the limit. |
too_many_integer_digits |
The amount has too many digits before the decimal point. |
not_positive |
The amount is zero or negative. |
not_public_unicast |
end_user_ip is not a public IPv4 or IPv6 address. Private, loopback, CGNAT, documentation and other special ranges are refused. |
from_or_to_required |
GET /pairs was called without from and from_network, or to and to_network. |
not_allowed_with_quote_id |
POST /orders was sent a field that belongs to the quote, such as amount. Send quote_id with the recipient fields only. |
requires_refund_address |
refund_memo was sent without refund_address. |
invalid_json |
The body is not valid JSON. |
invalid_encoding |
The body is not UTF-8. |
Fix the request before you send it again. An unknown or repeated query parameter is
refused before your key is checked and costs nothing; on POST /orders, a 400 costs
1 unit.
unauthorized
Section titled “unauthorized”HTTP 401. The request to a keyed endpoint has no Authorization header, a value
that is not a well-formed Bearer ktv_sk_… key, or a key that does not exist. The
response carries WWW-Authenticate: Bearer realm="kotova-x". Check that your server
sends the secret key, not the key ID (ktv_pk_…), and that it has not been cut short.
See Authentication.
key_revoked
Section titled “key_revoked”HTTP 401. The key was regenerated or deleted, or revoked by Kotova. Put the
current secret key on your servers, or create a new key on the
account API page. The response carries
WWW-Authenticate: Bearer realm="kotova-x".
affiliate_inactive
Section titled “affiliate_inactive”HTTP 403. The key exists, but the affiliate profile it belongs to is not active: it was deactivated or has expired, or the account that created the key no longer exists or no longer owns the profile. The account API page says that API access is paused. Write to api@kotova.io with your key ID.
not_found
Section titled “not_found”HTTP 404. There is no endpoint at this path. Check that the path starts with
/x/v1 and that you call api.kotova.io. The rates feed
also answers not_found for a file other than variable.xml and fixed.xml, and while
the feed is switched off.
order_not_found
Section titled “order_not_found”HTTP 404. GET /orders/{id} found no order with this id for your account.
details.id repeats the id. Only orders created through the API with one of your
account’s keys, current or earlier, are visible.
quote_not_found
Section titled “quote_not_found”HTTP 404. POST /orders found no quote with this quote_id for your account:
the id is wrong, the quote belongs to another account, or it is no longer stored
because it expired a while ago. details.quote_id repeats the id. Request a new
quote.
method_not_allowed
Section titled “method_not_allowed”HTTP 405. The endpoint does not support this HTTP method. The Allow header and
details.allowed list the methods it does support. HEAD works on the keyless GET
endpoints only.
quote_already_used
Section titled “quote_already_used”HTTP 409. A quote can be used for one order, and this one was already used.
details.quote_id is the quote and details.order_id the order created from it, or
null while that order’s outcome is unknown. If you did not mean to create a second
order, read the one in details.order_id. For a new order, request a new quote.
idempotency_conflict
Section titled “idempotency_conflict”HTTP 409. This Idempotency-Key was already used, within the last 24 hours, with
a different request body. details.header is Idempotency-Key. A retry must repeat
the original body exactly; a new order needs a new key.
idempotency_in_progress
Section titled “idempotency_in_progress”HTTP 409. A request with this Idempotency-Key is still being processed. Wait for
Retry-After (2 seconds) and send the same request again with the same key.
quote_expired
Section titled “quote_expired”HTTP 410. The quote’s expires_at has passed: 60 seconds after the source
answered for a fixed quote, 120 seconds for a variable one. details.quote_id and
details.expires_at identify it. Request a new quote and show it to your user again,
with its source notices, since the amounts or the sources may have changed.
payload_too_large
Section titled “payload_too_large”HTTP 413. The request body is larger than 16 KB. details.max_bytes gives the
limit (16384).
unsupported_media_type
Section titled “unsupported_media_type”HTTP 415. A POST body must be JSON, sent with Content-Type: application/json
(with charset=utf-8 or no charset).
unknown_currency
Section titled “unknown_currency”HTTP 422. This currency on this network is not in the catalogue.
details.currency and details.network repeat what you sent, and details.side
(from or to) says which side of a quote or pair it was; it is absent on
POST /addresses/validate. Check the codes against
GET /currencies.
currency_unavailable
Section titled “currency_unavailable”HTTP 422. The currency exists on this network, but cannot be used in this
direction right now: it cannot be sent (details.side: "from") or received
(details.side: "to"). details.currency and details.network name it. The send
and receive flags in GET /currencies show what is usable.
same_currency
Section titled “same_currency”HTTP 422. The currency and network to send and to receive are the same.
details.currency and details.network name them.
pair_unavailable
Section titled “pair_unavailable”HTTP 422. No source offers this pair right now. details.from and details.to
name the two sides. GET /pairs shows what a currency can be swapped into.
no_source_available
Section titled “no_source_available”HTTP 422. Sources serve this pair, but none is left that could quote this request,
usually because your request’s filters excluded them. details.reasons lists each
reason with the sources it excluded:
"details": { "reasons": [ { "reason": "freeze_protection", "sources": ["sideshift", "changenow", "ffio"] }, { "reason": "rate_type_unsupported", "sources": ["chainflip"] } ]}reason |
What excluded the sources |
|---|---|
excluded_by_request |
You listed them in exclude_sources. |
freeze_protection |
You asked for freeze_protection, and they can freeze funds. |
rate_type_unsupported |
They do not offer the rate_type you asked for. |
end_user_ip_required |
They need end_user_ip, and the request had none. |
refund_address_required |
They need a refund address when the order is created, and you sent can_provide_refund_address: false. |
create_budget_exhausted |
They cannot take more orders through the API for the moment. |
New reasons may be added. Relax the filter that excluded the most sources, send
end_user_ip, or try the other rate type.
The list can be empty when the remaining sources declined the request for reasons of their own. Then try another amount, or ask again later.
amount_below_min
Section titled “amount_below_min”HTTP 422. The amount is below the minimum of every source that could quote it.
details.min_from_amount is the smallest minimum, details.max_from_amount the
largest maximum (null when there is none), and details.currency and
details.network the currency they are counted in. The bounds are always in the
currency your user sends, even for a quote with amount_type: "to". Ask your user for
a larger amount.
amount_above_max
Section titled “amount_above_max”HTTP 422. The amount is above the maximum of every source that could quote it.
details has the same fields as for amount_below_min. Ask your
user for a smaller amount, or split the swap.
refund_address_required
Section titled “refund_address_required”HTTP 422. The source of this quote needs a refund address when the order is
created, and POST /orders had none. The quote announced it with
requirements.refund_address: "required". details.field is refund_address,
details.network the network the refund goes to (the one your user sends from) and
details.source the source. Send the order again with a refund_address on that
network.
invalid_address
Section titled “invalid_address”HTTP 422. An address or a memo in POST /orders was refused. details.field says
which one, and details.reason why:
format: the address indetails.field(to_addressorrefund_address) does not match the address format of the network indetails.network. A recipient address belongs to the network your user receives on, a refund address to the network they send from.POST /addresses/validateruns the same check before you create the order.rejected_by_source: the source indetails.sourcerefused whatdetails.fieldnames:to_address,to_memo,refund_addressorrefund_memo. This also counts when the source says so only in its message: it names the field, or says the address or memo is invalid, missing or similar. A message that only mentions an address does not count. Nothing was created, and the order is not tried at another source, whether or notretry_next_best_sourceis on. When the source did not say which value it refused,details.fieldis the best guess anddetails.fieldslists every candidate: check them all. When the refusing source was one of the quote’sfallback_sources,details.sources_triedlists every source tried, ending with the one that refused (see Sources tried).
"details": { "field": "to_address", "fields": ["to_address", "refund_address"], "reason": "rejected_by_source", "source": "changenow"}Ask your user for a correct address or memo, then create the order again; while the
quote has not expired, you can use the same quote_id.
memo_not_supported
Section titled “memo_not_supported”HTTP 422. A memo was sent for a network that has no memos. details.field is the
memo field (to_memo or refund_memo) and details.network the network. Leave the
memo out. GET /currencies shows, per network, whether a memo is supported.
rate_limited
Section titled “rate_limited”HTTP 429. A request limit was reached. Retry-After gives the seconds to wait,
and a refused request costs nothing. details.reason says which limit:
details.reason |
Limit | Other details |
|---|---|---|
| absent | Your account’s budget of weight units per 60-second window. | limit, remaining, reset_seconds, weight |
fanout |
Quotes per minute that asked a source live, at least 30. | limit, remaining, reset_seconds |
ip |
Calls to keyless endpoints that reach the server: 120 per minute per IP address. | limit, remaining, reset_seconds |
auth_failures |
Failed authentications: 30 per minute per IP address. | limit, remaining, reset_seconds |
See Rate limits.
order_cap_reached
Section titled “order_cap_reached”HTTP 429. Your account has reached one of its order caps. details.cap says which:
open_unfunded (orders still waiting for a deposit) or created_24h (orders created
in the last 24 hours). details.limit is the cap and details.current your count.
Wait for Retry-After; open orders stop counting once they are funded or expire. See
Order caps.
internal_error
Section titled “internal_error”HTTP 500. Something failed on Kotova’s side. If details.order_id is present,
POST /orders created that order but could not read it back: read it with
GET /orders/{id}, or send the same request again with the same Idempotency-Key.
If details.sources_tried is present, POST /orders failed on Kotova’s side while
creating the order at one of the quote’s fallback_sources: nothing was created, and
the quote and the Idempotency-Key are released (see Sources tried).
Otherwise retry later, and on POST /orders only with the same Idempotency-Key. If
it persists, write to api@kotova.io with the request id.
source_error
Section titled “source_error”HTTP 502. No source created the order. details.source names the last source
asked, and details.retryable says what to do:
true: nothing was created. Request a new quote and create the order from it.falsewithoutdetails.reason: the source refused the order. Do not send it again unchanged; request a new quote before you try again.falsewithdetails.reason: "outcome_unknown": the source did not confirm the order, and it may exist there. To prevent a duplicate, the quote and theIdempotency-Keystay used. Write to api@kotova.io with the request id before you create another order for the same swap.falsewithdetails.reason: "idempotency_outcome_unknown": an earlier request with thisIdempotency-Keyhad an unknown outcome, so the key stays blocked for 24 hours.details.sourceis absent. Write to api@kotova.io with the request id.
With retry_next_best_source (the default), a source_error from a create attempt
also carries details.sources_tried, which lists every source the order was tried at:
see Sources tried. The idempotency_outcome_unknown answer does
not.
Sources tried
Section titled “Sources tried”By default, POST /orders tries the quote’s source and then the quote’s
fallback_sources (see Next best source). When none
of them takes the order, the 502 source_error or 503 source_unavailable carries
details.sources_tried: one entry per source, in the order they were tried, with
source (the slug) and reason. So does a 422 invalid_address or a 500
internal_error from one of the quote’s fallback_sources; the same answers from the
quote’s own source have no sources_tried. The 502 with details.reason: idempotency_outcome_unknown, which answers a retry with a blocked Idempotency-Key,
has none either.
reason |
What happened |
|---|---|
disabled, paused, busy |
The source was not available, and was not asked. |
rejected |
The source was asked and refused the order. Nothing was created. |
unavailable |
The source was asked and could not create the order right now. Nothing was created. |
rate_limited |
The source was asked and answered that it is rate limited. Nothing was created. |
invalid_address |
The source was asked and refused an address or a memo. No other source is asked: always the last entry, with a 422 invalid_address. |
internal |
The source was asked, and Kotova’s side failed before anything was created. Always the last entry, with a 500 internal_error. |
outcome_unknown |
The source was asked and did not confirm the order, which may exist there. Always the last entry. |
refund_address_required |
A fallback source that needs a refund address was skipped, because the order had none. |
deadline |
A fallback source was skipped, because the request had already run too long to ask another source, or its create could not have finished in time. |
"details": { "source": "changelly", "retryable": true, "sources_tried": [ { "source": "sideshift", "reason": "unavailable" }, { "source": "changenow", "reason": "busy" }, { "source": "changelly", "reason": "unavailable" } ]}The other details describe the last source asked. New reasons may be added. Without
retry_next_best_source, only the quote’s source is tried, and sources_tried is
absent.
source_unavailable
Section titled “source_unavailable”HTTP 503. No source of your quote could take an order right now: the quote’s source
and, with retry_next_best_source (the default), its fallback sources were not
available, or the last one asked was rate limited. Nothing was created.
details.source names the last source asked, or the quote’s source when none was
asked; details.reason says why (disabled, paused, busy or rate_limited), and
details.requote is true. With retry_next_best_source, details.sources_tried
lists every source (see Sources tried). Request a new quote, which
may come from other sources, and create the order from it. Retry-After is sent as
well.
sources_unavailable
Section titled “sources_unavailable”HTTP 503. POST /quote got no usable quote from any source, and at least one
source could not be asked right now, for a temporary reason such as a timeout, an
error, its own rate limit or a regional block. Other sources may have answered, for example that they do not serve the pair.
details.retry_after_seconds and the Retry-After header give the wait (10 seconds).
Then ask for the quote again.
service_unavailable
Section titled “service_unavailable”HTTP 503. The API cannot serve the request right now. details.reason says why:
details.reason |
Meaning |
|---|---|
disabled |
The API is not enabled where the request was sent: it is switched off, or the host does not serve it. Use https://api.kotova.io/x/v1. |
maintenance |
The API is under maintenance. Retry later. |
catalog_missing |
The currency catalogue is temporarily unavailable. |
storage_unavailable |
A storage service the API needs is temporarily unavailable. Retry shortly. |
New reasons may be added. Wait for Retry-After when it is sent, otherwise retry
after a growing pause. GET /health also answers 503 while the
API cannot serve requests.
Warnings
Section titled “Warnings”A successful answer may carry non-fatal notes in warnings. Each has a code, a
message for people, and fields of its own:
"warnings": [ { "code": "end_user_ip_missing", "message": "Sources that quote per end user were skipped because end_user_ip was not sent; sending it may improve the rate.", "sources": ["sideshift"] }]code |
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 code was used. Switch to canonical. |
unsupported_lang |
field, supported |
lang is not one of the supported languages, so the source notice is in English. |
end_user_ip_missing |
sources |
These sources need end_user_ip and were skipped. Sending it may give a better quote. |
sources_temporarily_skipped |
sources |
These sources could not be asked right now. A later quote may be better. |
source_fallback |
quoted_source, reason |
POST /orders only. The quote’s source could not take the order, so it was placed at the next of the quote’s fallback_sources that could. The order’s source names that source, and its amounts are that source’s. reason is why the quote’s source did not take it: disabled, paused or busy (not available), or rejected, unavailable or rate_limited (asked, nothing created). It comes with the 201 and with its Idempotency-Key replays, never with GET /orders/{id}: there, compare the order’s source.slug with the quote’s. |
refund_address_on_file |
source, intake |
POST /orders only. The order’s source takes no refund address when the order is created, so the refund_address is kept with the order and refund.forwarded is false. intake is the source’s refund_address_forwarding: when_needed (given to the source only if the order needs a refund) or none (kept for support; the source handles refunds itself). It comes with the 201 and with its Idempotency-Key replays, never with GET /orders/{id}. See Refund addresses. |
New warning codes may be added: ignore those you do not know.
Contacting support
Section titled “Contacting support”Write to api@kotova.io. Include the request_id from the
error body (or the X-Request-Id header of any response) and, for an order, its id
(X-XXXXXX). Never send your secret key: support never needs it.