# Errors

> The error format, every error code with its HTTP status and meaning, which requests to retry and how, and what to send support.

- Source: https://docs.kotova.io/x/api/errors
- API base URL: https://api.kotova.io/x/v1
- OpenAPI document: https://api.kotova.io/x/v1/openapi.json

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

Every error the API answers (HTTP status 400 or above) has this body:

```json
{
  "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](https://docs.kotova.io/x/api/errors#warnings).

## HTTP statuses

| Status | Codes |
| --- | --- |
| 400 | [`invalid_request`](https://docs.kotova.io/x/api/errors#invalid_request) |
| 401 | [`unauthorized`](https://docs.kotova.io/x/api/errors#unauthorized), [`key_revoked`](https://docs.kotova.io/x/api/errors#key_revoked) |
| 403 | [`affiliate_inactive`](https://docs.kotova.io/x/api/errors#affiliate_inactive) |
| 404 | [`not_found`](https://docs.kotova.io/x/api/errors#not_found), [`order_not_found`](https://docs.kotova.io/x/api/errors#order_not_found), [`quote_not_found`](https://docs.kotova.io/x/api/errors#quote_not_found) |
| 405 | [`method_not_allowed`](https://docs.kotova.io/x/api/errors#method_not_allowed) |
| 409 | [`quote_already_used`](https://docs.kotova.io/x/api/errors#quote_already_used), [`idempotency_conflict`](https://docs.kotova.io/x/api/errors#idempotency_conflict), [`idempotency_in_progress`](https://docs.kotova.io/x/api/errors#idempotency_in_progress) |
| 410 | [`quote_expired`](https://docs.kotova.io/x/api/errors#quote_expired) |
| 413 | [`payload_too_large`](https://docs.kotova.io/x/api/errors#payload_too_large) |
| 415 | [`unsupported_media_type`](https://docs.kotova.io/x/api/errors#unsupported_media_type) |
| 422 | [`unknown_currency`](https://docs.kotova.io/x/api/errors#unknown_currency), [`currency_unavailable`](https://docs.kotova.io/x/api/errors#currency_unavailable), [`same_currency`](https://docs.kotova.io/x/api/errors#same_currency), [`pair_unavailable`](https://docs.kotova.io/x/api/errors#pair_unavailable), [`no_source_available`](https://docs.kotova.io/x/api/errors#no_source_available), [`amount_below_min`](https://docs.kotova.io/x/api/errors#amount_below_min), [`amount_above_max`](https://docs.kotova.io/x/api/errors#amount_above_max), [`refund_address_required`](https://docs.kotova.io/x/api/errors#refund_address_required), [`invalid_address`](https://docs.kotova.io/x/api/errors#invalid_address), [`memo_not_supported`](https://docs.kotova.io/x/api/errors#memo_not_supported) |
| 429 | [`rate_limited`](https://docs.kotova.io/x/api/errors#rate_limited), [`order_cap_reached`](https://docs.kotova.io/x/api/errors#order_cap_reached) |
| 500 | [`internal_error`](https://docs.kotova.io/x/api/errors#internal_error) |
| 502 | [`source_error`](https://docs.kotova.io/x/api/errors#source_error) |
| 503 | [`source_unavailable`](https://docs.kotova.io/x/api/errors#source_unavailable), [`sources_unavailable`](https://docs.kotova.io/x/api/errors#sources_unavailable), [`service_unavailable`](https://docs.kotova.io/x/api/errors#service_unavailable) |

## 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`](https://docs.kotova.io/x/api/errors#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 `201` with that order and
  `Idempotent-Replayed: true`, and costs 1 unit;
- if the first request is still running, the retry answers `409`
  [`idempotency_in_progress`](https://docs.kotova.io/x/api/errors#idempotency_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

### 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

**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](https://docs.kotova.io/x/api/authentication).

### 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](https://app.kotova.io/account/api). The response carries
`WWW-Authenticate: Bearer realm="kotova-x"`.

### 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](mailto:api@kotova.io) with your key ID.

### 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

**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

**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

**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

**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

**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

**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

**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

**HTTP 413.** The request body is larger than 16 KB. `details.max_bytes` gives the
limit (16384).

### 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

**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`](https://docs.kotova.io/x/api/currencies).

### 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

**HTTP 422.** The currency and network to send and to receive are the same.
`details.currency` and `details.network` name them.

### 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

**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:

```json
"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

**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

**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`](https://docs.kotova.io/x/api/errors#amount_below_min). Ask your
user for a smaller amount, or split the swap.

### 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

**HTTP 422.** An address or a memo in `POST /orders` was refused. `details.field` says
which one, and `details.reason` why:

- `format`: the address in `details.field` (`to_address` or `refund_address`) does
  not match the address format of the network in `details.network`. A recipient
  address belongs to the network your user receives on, a refund address to the
  network they send from. `POST /addresses/validate` runs the same check before you
  create the order.
- `rejected_by_source`: the source in `details.source` refused what `details.field`
  names: `to_address`, `to_memo`, `refund_address` or `refund_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 not
  `retry_next_best_source` is on. When the source did not say which value it refused,
  `details.field` is the best guess and `details.fields` lists every candidate: check
  them all. When the refusing source was one of the quote's `fallback_sources`,
  `details.sources_tried` lists every source tried, ending with the one that refused
  (see [Sources tried](https://docs.kotova.io/x/api/errors#sources-tried)).

```json
"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

**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

**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](https://docs.kotova.io/x/api/rate-limits).

### 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](https://docs.kotova.io/x/api/rate-limits#order-caps).

### 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](https://docs.kotova.io/x/api/errors#sources-tried)).
Otherwise retry later, and on `POST /orders` only with the same `Idempotency-Key`. If
it persists, write to [api@kotova.io](mailto:api@kotova.io) with the request id.

### 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.
- `false` without `details.reason`: the source refused the order. Do not send it
  again unchanged; request a new quote before you try again.
- `false` with `details.reason: "outcome_unknown"`: the source did not confirm the
  order, and it may exist there. To prevent a duplicate, the quote and the
  `Idempotency-Key` stay used. Write to [api@kotova.io](mailto:api@kotova.io) with
  the request id before you create another order for the same swap.
- `false` with `details.reason: "idempotency_outcome_unknown"`: an earlier request
  with this `Idempotency-Key` had an unknown outcome, so the key stays blocked for 24
  hours. `details.source` is absent. Write to
  [api@kotova.io](mailto: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](https://docs.kotova.io/x/api/errors#sources-tried). The `idempotency_outcome_unknown` answer does
not.

#### Sources tried

By default, `POST /orders` tries the quote's source and then the quote's
`fallback_sources` (see [Next best source](https://docs.kotova.io/x/api/orders#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. |

```json
"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

**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](https://docs.kotova.io/x/api/errors#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

**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

**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`](https://docs.kotova.io/x/api/utilities) also answers `503` while the
API cannot serve requests.

## Warnings

A successful answer may carry non-fatal notes in `warnings`. Each has a `code`, a
`message` for people, and fields of its own:

```json
"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](https://docs.kotova.io/x/api/orders#refund-addresses). |

New warning codes may be added: ignore those you do not know.

## Contacting support

Write to [api@kotova.io](mailto: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.

> [!TIP]
>
> Log the `X-Request-Id` of every request you make. It is the fastest way to find a
> request in Kotova's logs, and it contains nothing secret.
