# XML rates feed

> The public XML feed of Kotova X rates for rate monitors: its URLs, its format, the currency codes it uses, how fresh it is and what a line in it means.

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

The XML rates feed publishes Kotova X rates in the XML format that rate monitors such
as BestChange read. It is public and needs no key. Its rates are indicative: the rate
your user gets is the one in a quote.

## Fetching the feed

There are two documents, one per rate type:

| URL | Rates |
|---|---|
| `https://api.kotova.io/x/v1/rates/variable.xml` | Variable rates (`variable`): the final amount is set when the deposit arrives. |
| `https://api.kotova.io/x/v1/rates/fixed.xml` | Fixed rates (`fixed`). |

Both answer `GET` and `HEAD`, need no key and carry no weight, and send
`Access-Control-Allow-Origin: *`. Send no `Authorization` header and no query string:
any query string at all, including an empty one such as `?&`, is a 400
[`invalid_request`](https://docs.kotova.io/x/api/errors#invalid_request), so the URLs cannot be cache-busted.

**curl**

```bash
curl -si https://api.kotova.io/x/v1/rates/variable.xml
```

**JavaScript**

```js
const res = await fetch('https://api.kotova.io/x/v1/rates/variable.xml');
const status = res.headers.get('X-Kotova-Feed-Status'); // "ok" or "degraded"
const xml = await res.text();
```

**Python**

```python
import requests

res = requests.get("https://api.kotova.io/x/v1/rates/variable.xml", timeout=10)
res.raise_for_status()
status = res.headers["X-Kotova-Feed-Status"]  # "ok" or "degraded"
xml = res.text
```

A successful answer has these headers (values from an example):

```http
HTTP/2 200
content-type: application/xml; charset=utf-8
cache-control: public, s-maxage=10, stale-while-revalidate=20
etag: "9c1f0e4b7a2d6385"
last-modified: Tue, 10 Nov 2026 10:15:04 GMT
x-kotova-feed-status: ok
x-kotova-feed-sources: ccecash,ffio
x-kotova-feed-generated-at: 2026-11-10T10:15:04.512Z
x-request-id: req_8fK2mQ9xLp3vTz7nWc1bYd4h
```

| Header | Meaning |
|---|---|
| `Cache-Control` | The CDN keeps a document for 10 seconds, and may serve it for 20 more while it fetches a new one. |
| `ETag` | A strong validator, `"` + 16 hexadecimal characters + `"`. Send it back in `If-None-Match` to get a `304 Not Modified` with no body when nothing has changed. |
| `Last-Modified` | When the document was built. `If-Modified-Since` also gives a 304, when you send no `If-None-Match`. |
| `X-Kotova-Feed-Status` | `ok` when the rates of every source the feed is built from are fresh and the document has at least one line; otherwise `degraded`: some sources' rates are missing, or the document is empty. |
| `X-Kotova-Feed-Sources` | The slugs of the sources whose rates are in the document, separated by commas, or `none`. |
| `X-Kotova-Feed-Generated-At` | When the document was built, in ISO 8601 with milliseconds. |

The other answers:

| Status | Code | When |
|---|---|---|
| 304 | | `If-None-Match` or `If-Modified-Since` matches the current document. |
| 400 | [`invalid_request`](https://docs.kotova.io/x/api/errors#invalid_request) | A query string was sent. `details.reason` is `unknown_parameter`. |
| 404 | [`not_found`](https://docs.kotova.io/x/api/errors#not_found) | A file name other than `variable.xml` or `fixed.xml`, or the feed is switched off. |
| 405 | [`method_not_allowed`](https://docs.kotova.io/x/api/errors#method_not_allowed) | A method other than `GET`, `HEAD` or `OPTIONS`. |
| 429 | [`rate_limited`](https://docs.kotova.io/x/api/errors#rate_limited) | More than 120 requests a minute from one IP address reached the server. Answers from the CDN do not count, and BestChange's monitoring robots are exempt. |
| 503 | [`service_unavailable`](https://docs.kotova.io/x/api/errors#service_unavailable) | The API is not available. |

**Endpoint:** `GET https://api.kotova.io/x/v1/rates/{file}` (Rates feed (BestChange XML))

- Authentication: none (public endpoint, send no key)
- Weight: none
- Parameters:
  - `file` (path, required): `variable.xml` or `fixed.xml`. One of: `variable.xml`, `fixed.xml`.

Example request:

```sh
curl 'https://api.kotova.io/x/v1/rates/variable.xml'
```

Example response (`200`):

```xml
<?xml version="1.0" encoding="UTF-8"?>
<rates>
<item><from>BTC</from><to>USDTTRC20</to><in>1</in><out>68021.33412</out><amount>412000.5</amount><minamount>0.00092</minamount><maxamount>1.5</maxamount><tofee>0.1</tofee></item>
<item><from>ETH</from><to>BTC</to><in>1</in><out>0.0361</out><amount>12.5</amount><minamount>0.01</minamount><maxamount>40</maxamount></item>
<item><from>XMR</from><to>BTC</to><in>1</in><out>0.002511873</out><amount>18.5</amount><minamount>0.25</minamount><maxamount>310</maxamount><tofee>0.00012</tofee><param>manual</param></item>
</rates>

```

## Format

The feed uses BestChange's XML rates format: an XML declaration, a `rates` element, and
one `item` element per line for each direction, sorted by `from` and then by `to`.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<rates>
<item><from>BTC</from><to>USDTTRC20</to><in>1</in><out>68021.33412</out><amount>412000.5</amount><minamount>0.00092</minamount><maxamount>1.5</maxamount><tofee>0.1</tofee></item>
<item><from>ETH</from><to>BTC</to><in>1</in><out>0.0361</out><amount>12.5</amount><minamount>0.01</minamount><maxamount>40</maxamount></item>
<item><from>XMR</from><to>BTC</to><in>1</in><out>0.002511873</out><amount>18.5</amount><minamount>0.25</minamount><maxamount>310</maxamount><tofee>0.00012</tofee><param>manual</param></item>
</rates>
```

The elements of an `item` always come in this order:

| Element | Meaning |
|---|---|
| `from` | The currency sent, as a [BestChange code](https://docs.kotova.io/x/api/rates-feed#currency-codes). |
| `to` | The currency received, as a BestChange code. |
| `in` | Always `1`. |
| `out` | The amount of `to` paid out per 1 unit of `from`, before `tofee`. |
| `amount` | The reserve: how much of `to` the source reports it can pay out, in units of `to`. |
| `minamount` | The smallest amount of `from` the source accepts. |
| `maxamount` | The largest amount of `from` the source accepts. |
| `tofee` | Optional. A fee taken from the payout: an amount in units of `to`, or a percentage written as `N%`. |
| `param` | Optional. BestChange flags passed on from the source, separated by commas: `manual`, `verifying`, `reg`, `otherin` or `otherout`. |

Numbers are plain decimals: no exponent, no sign, no unit, no spaces and no thousands
separators. The document has no comments, no other elements and no per-line attribution;
which sources contributed is in the `X-Kotova-Feed-Sources` header. When there is nothing
to publish, the feed is the valid empty document:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<rates></rates>
```

## What a line means

A line says that, when the document was built, a source the API routes to was offering
this direction: it would take between `minamount` and `maxamount` of `from`, and pay out
about `out` units of `to` for each unit sent, minus `tofee`.

A line is indicative, not an offer. The feed is built from rates that sources publish
themselves, and only some sources publish them. A [quote](https://docs.kotova.io/x/api/quotes) asks every
source that serves the pair, at the moment your user asks, so the rate in a quote can be
higher or lower than the line, and with a variable rate the final amount is set when the
deposit arrives.

## How a line is chosen

A direction is published only when a quote could be routed to the source right now: both
currencies are in the [code table](https://docs.kotova.io/x/api/rates-feed#currency-codes) and active in the catalogue, the
source is active for the API and not paused, and it serves the direction (it is in the
`from` leg's `send_sources` and the `to` leg's `receive_sources`).

Each line is then checked. It is left out unless `out` is positive, `minamount` is
positive and below `maxamount`, the reserve is positive, and the fee at the minimum
amount is at most 10% of the payout there.

When more than one source offers the same direction, one of them is published, with all
of its values: the rate, the limits, the reserve and the fee always come from the same
source and are never mixed or averaged.

1. Each candidate's payout is `x × out − tofee` (or `x × out` minus the percentage) for
   an amount `x`.
2. When their ranges overlap, both are compared at the smallest amount both accept, the
   higher of the two minimums. When they do not, each is compared at its own minimum,
   per unit sent.
3. The higher payout wins. A tie goes to the wider range, then to the lower minimum.
4. A winner whose rate is more than 5% above the other candidate's is treated as
   suspect, and the other candidate is published instead.

Rates are only compared with each other, never with a market price.

### Rounding

Every rounding goes against your user, so a line never promises more than its source
offered:

| Value | Rounding |
|---|---|
| `out` | Cut off (not rounded) to 10 significant digits, with at most 18 decimal places. |
| `minamount` | Rounded up to `min(decimals, 8)` decimal places, where `decimals` are those of the `from` leg. |
| `maxamount` | Rounded down to the same number of decimal places. |
| `amount` | Rounded down to `min(decimals, 8)` decimal places of the `to` leg. |
| `tofee` | Rounded up to `min(decimals, 8)` decimal places of the `to` leg. A percentage is published as the source gave it. |

## Freshness

The feed never publishes stale rates:

- A document is rebuilt from the sources' latest rates when it is more than 10 seconds
  old and someone asks for it. Up to 60 seconds old, it is still served while the new
  one is built; older than that, the request waits for the rebuild. A document more than
  two minutes old is never served.
- A source's rates are left out once they are more than two minutes old. When no
  source's rates are fresh, the feed is the empty document, with
  `X-Kotova-Feed-Status: degraded`.
- On top of that, the CDN caches each document for 10 seconds (`s-maxage=10`).

`X-Kotova-Feed-Generated-At` tells you exactly how old the document you received is.
Polling more often than every 10 seconds returns the same document; use `If-None-Match`
so that an unchanged one costs a 304.

> [!NOTE]
> **Degraded is not an error**
>
> `degraded` means that some rates are missing, not that the published ones are wrong. A
> monitor should read the lines that are there. An empty document means there is nothing to
> publish right now.

## Currency codes

The feed uses BestChange currency codes, not the API's identifiers. It publishes only
the codes in this table; a direction whose currency has no code here is left out. Each
code stands for one leg, with `currency` and `network` as in
[Currencies, networks and icons](https://docs.kotova.io/x/api/currencies).

| BestChange code | `currency` | `network` |
|---|---|---|
| `BTC` | `BTC` | `btc` |
| `ETH` | `ETH` | `eth` |
| `ETHBEP20` | `ETH` | `bsc` |
| `ETHARBTM` | `ETH` | `arbitrum` |
| `ETHOPTM` | `ETH` | `op` |
| `ETHBASE` | `ETH` | `base` |
| `BNBBEP20` | `BNB` | `bsc` |
| `LTC` | `LTC` | `ltc` |
| `BCH` | `BCH` | `bch` |
| `XMR` | `XMR` | `xmr` |
| `SOL` | `SOL` | `sol` |
| `TRX` | `TRX` | `trx` |
| `XRP` | `XRP` | `xrp` |
| `DOGE` | `DOGE` | `doge` |
| `DASH` | `DASH` | `dash` |
| `ZEC` | `ZEC` | `zec` |
| `GRAM` | `GRAM` | `ton` |
| `USDTERC20` | `USDT` | `eth` |
| `USDTTRC20` | `USDT` | `trx` |
| `USDTBEP20` | `USDT` | `bsc` |
| `USDTSOL` | `USDT` | `sol` |
| `USDTPOLYGON` | `USDT` | `matic` |
| `USDTTON` | `USDT` | `ton` |
| `USDTARBTM` | `USDT` | `arbitrum` |
| `USDCERC20` | `USDC` | `eth` |
| `USDCSOL` | `USDC` | `sol` |
| `USDCBEP20` | `USDC` | `bsc` |
| `USDCBASE` | `USDC` | `base` |
| `USDCARBTM` | `USDC` | `arbitrum` |
| `USDCPOLYGON` | `USDC` | `matic` |
| `POL` | `POL` | `matic` |
| `ADA` | `ADA` | `ada` |
| `XLM` | `XLM` | `xlm` |

Bitcoin on Lightning (`BTCLN`) is not in the feed in v1. Codes may be added to this
table over time.
