# Rate limits

> How request weights and the per-account budget work, which response headers report what is left, what to do when you reach the limit, and how to ask for a higher one.

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

Requests are limited per account, not per key, so creating a new key never resets the
budget. Every request costs a weight, and the weights of all your account's requests
within a minute must stay inside its limit.

## How the budget works

Your account has a budget of weight units per 60-second window. The default is
**250 units**. Windows follow the clock: each one starts at the full minute (UTC) and
resets for everyone at the same moment, rather than 60 seconds after your first
request.

Each keyed request is checked first and charged second. If its weight still fits into
what is left of the window, it is charged and answered. If it does not fit, it is
refused with `429` [`rate_limited`](https://docs.kotova.io/x/api/errors#rate_limited) and costs nothing.
There is no penalty for being refused: once the window resets, the full budget is
available again.

The budget is shared by every request of your account. `GET /me` reports your limit
and today's usage, and the [account API page](https://app.kotova.io/account/api) shows
the units used in the current minute and the requests of the last seven days.

## Endpoint weights

| Operation | Method and path | Weight |
| --- | --- | --- |
| `createOrder` | `POST /orders` | 50 |
| `createQuote` | `POST /quote` | 1 |
| `getOrder` | `GET /orders/{id}` | 1 |
| `listOrders` | `GET /orders` | 1 |
| `validateAddress` | `POST /addresses/validate` | 1 |
| `getMe` | `GET /me` | 1 |
| Keyless endpoints | `/health`, `/openapi.json`, `/currencies`, `/networks`, `/sources`, `/pairs`, the rates feed and the index | none |

The OpenAPI document gives each keyed operation's weight as `x-kotova-weight`. At the
default limit of 250 units, an account can create at most five orders a minute, and
fewer when it also quotes and reads in the same minute.

## What a request costs

A keyed request costs its weight whatever it answers, errors included: a quote
answered with `422` [`amount_below_min`](https://docs.kotova.io/x/api/errors#amount_below_min), for example,
still costs one unit. The exceptions:

- **Refused by the limit.** A request refused with `429` `rate_limited` costs nothing.
  Its `X-Kotova-Weight` header reports what it would have cost.
- **Refused before the limit is checked.** These cost nothing and carry no rate-limit
  headers: every authentication failure (`401` [`unauthorized`](https://docs.kotova.io/x/api/errors#unauthorized),
  `401` [`key_revoked`](https://docs.kotova.io/x/api/errors#key_revoked), `403`
  [`affiliate_inactive`](https://docs.kotova.io/x/api/errors#affiliate_inactive), `429` `rate_limited` with
  `details.reason: "auth_failures"`), and an unknown or repeated query parameter
  (`400` [`invalid_request`](https://docs.kotova.io/x/api/errors#invalid_request)).
- **A quote refused by the fan-out allowance.** `429` `rate_limited` with
  `details.reason: "fanout"` costs nothing (see
  [Quotes that ask the sources](https://docs.kotova.io/x/api/rate-limits#quotes-that-ask-the-sources)).
- **Creating an order that fails before a source is contacted.** `POST /orders`
  costs 1 instead of 50 when it answers an error before the API has asked any source
  to create the order: an invalid body, a quote that is unknown, used or expired, an
  address that fails the format check, an order cap, or sources that are all
  unavailable, for example. Once a source has been asked, the full 50 stays charged,
  once, whatever the outcome and however many of the quote's sources were tried.
- **A replay of an order.** A `POST /orders` that repeats an earlier request with the
  same `Idempotency-Key` and body answers that order (`Idempotent-Replayed: true`)
  and costs 1.

Units given back go into the window the request was charged in.

## Response headers

Keyed responses carry these headers once the key is authenticated. Your code should
still work when they are missing.

| Header | Example | Meaning |
| --- | --- | --- |
| `X-Kotova-Weight` | `1` | Units this request was charged. On a refused request, what it would have cost. |
| `RateLimit-Policy` | `"units";q=250;w=60` | Your budget: `q` units per window of `w` seconds. |
| `RateLimit` | `"units";r=187;t=34` | `r` units left in this window, `t` seconds until it resets. |
| `X-RateLimit-Limit` | `250` | Units per 60-second window. |
| `X-RateLimit-Remaining` | `187` | Units left in this window. |
| `X-RateLimit-Reset` | `34` | Seconds until the window resets. |
| `Retry-After` | `34` | Seconds to wait before retrying. Sent with every `429`, and with some `409` and `503` answers. |

A complete set, as it arrives with a quote:

```http
HTTP/2 200
X-Request-Id: req_8fK2mQ9xLp3vTz7nWc1bYd4h
X-Kotova-Weight: 1
RateLimit-Policy: "units";q=250;w=60
RateLimit: "units";r=187;t=34
X-RateLimit-Limit: 250
X-RateLimit-Remaining: 187
X-RateLimit-Reset: 34
X-Kotova-Quote-Cache: miss
```

Keyless responses carry no rate-limit headers, because they cost no units.

## When you reach the limit

A request that does not fit is answered `429` with `Retry-After`:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit of 250 units per 60 s reached. Retry in 34 s.",
    "request_id": "req_8fK2mQ9xLp3vTz7nWc1bYd4h",
    "details": { "limit": 250, "remaining": 0, "reset_seconds": 34, "weight": 1 },
    "doc_url": "https://docs.kotova.io/x/api/errors#rate_limited"
  }
}
```

Wait for the number of seconds in `Retry-After`, then send the request again. Better,
watch `X-RateLimit-Remaining` and slow down before you reach zero, keeping room for
the 50 units an order costs.

**curl**

```sh
# -D - prints the response headers, including the rate-limit ones.
curl -sS -D - -o /dev/null https://api.kotova.io/x/v1/me \
  -H "Authorization: Bearer $KOTOVA_API_KEY"
```

**JavaScript**

```js
const BASE = 'https://api.kotova.io/x/v1';
const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));

// Retries a request refused with 429 after Retry-After, up to three times.
async function kotova(path, init = {}, attempt = 0) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${process.env.KOTOVA_API_KEY}`, ...init.headers },
  });
  if (res.status === 429 && attempt < 3) {
    await sleep(Number(res.headers.get('Retry-After')) || 1);
    return kotova(path, init, attempt + 1);
  }
  return res;
}
```

**Python**

```python
import os
import time
import requests

BASE = "https://api.kotova.io/x/v1"
API_KEY = os.environ["KOTOVA_API_KEY"]

def kotova(method, path, attempts=3, **kwargs):
    """Retries a request refused with 429 after Retry-After, up to three times."""
    headers = {"Authorization": f"Bearer {API_KEY}", **kwargs.pop("headers", {})}
    for attempt in range(attempts + 1):
        res = requests.request(method, f"{BASE}{path}", headers=headers, timeout=100, **kwargs)
        if res.status_code != 429 or attempt == attempts:
            return res
        time.sleep(int(res.headers.get("Retry-After", 1)))
```

A retry of `POST /orders` must carry the same `Idempotency-Key` and the same body as
the first attempt, so that it can never create a second order. See
[Errors](https://docs.kotova.io/x/api/errors#retrying-without-duplicates).

## Quotes that ask the sources

Quotes are first looked up in a shared cache that keeps the sources' answers for up to
30 seconds for a fixed rate and 60 seconds for a variable one. A quote the cache cannot
answer asks the sources again, and the response says so in
`X-Kotova-Quote-Cache: miss` (`hit` when the cache answered). See
[Caching and the fan-out allowance](https://docs.kotova.io/x/api/quotes#caching-and-the-fan-out-allowance).

Besides their weight, quotes that ask the sources count against a separate allowance
per account and minute: your limit divided by 8, rounded down, and at least 30. Over
it, `POST /quote` answers `429` `rate_limited` with `details.reason: "fanout"` and
`Retry-After`, and the refused quote costs nothing. Many quotes for slightly different
amounts each ask the sources; reusing amounts lets the cache answer more of them.

## Order caps

Two caps limit how many orders an account can have, whatever its budget:

- **Open orders waiting for a deposit:** 20 × your limit ÷ 250. These are orders still
  in `waiting_for_deposit`, created with any of your account's keys.
- **Orders created in the last 24 hours:** 100 × your limit ÷ 250.

Both are rounded down, and each is at least 1. Over either cap, `POST /orders` answers
`429` [`order_cap_reached`](https://docs.kotova.io/x/api/errors#order_cap_reached), with `details.cap`
(`open_unfunded` or `created_24h`), `details.limit`, `details.current` and
`Retry-After`. An open order stops counting once it is funded or expires.

| Rate limit (units per minute) | Quotes that ask the sources, per minute | Open orders waiting for a deposit | Orders per 24 hours |
| --- | --- | --- | --- |
| 250 (default) | 31 | 20 | 100 |
| 500 | 62 | 40 | 200 |
| 1000 | 125 | 80 | 400 |
| 2500 | 312 | 200 | 1000 |
| 5000 | 625 | 400 | 2000 |

## Keyless endpoints

Keyless endpoints cost no units. Most of their answers come from the CDN cache, and
those count against nothing. Calls that reach the server are limited to 120 per minute
per IP address (per /64 for IPv6); the XML rates feed has its own allowance of the
same size, and BestChange's monitoring robots are exempt from it. Over the limit, the
answer is `429` `rate_limited` with `details.reason: "ip"` and `Retry-After`.

The surest way to stay well inside it is to send no key, no varying query strings and
no cache-busting headers, so that the CDN can answer, and to cache the catalogue on
your side: it changes rarely, and its responses carry an `ETag` you can send back as
`If-None-Match` to get a `304`.

## When a source is busy

Sources have limits of their own, which show up in these answers:

- A source that cannot be asked right now is left out of a quote, and the quote lists
  it in a `sources_temporarily_skipped` warning. When no source gave a usable quote and
  at least one could not be asked right now, `POST /quote` answers `503`
  [`sources_unavailable`](https://docs.kotova.io/x/api/errors#sources_unavailable) with `Retry-After`
  (10 seconds).
- A source that cannot take more orders through the API for the moment is left out of
  quotes. If it was the only one, the quote fails with `422`
  [`no_source_available`](https://docs.kotova.io/x/api/errors#no_source_available) and the reason
  `create_budget_exhausted`.
- When the source of your quote cannot take the order, `POST /orders` places it at the
  next of the quote's fallback sources that can (see
  [Next best source](https://docs.kotova.io/x/api/orders#next-best-source)). When none of them is
  available, or with `retry_next_best_source: false`, it answers `503`
  [`source_unavailable`](https://docs.kotova.io/x/api/errors#source_unavailable). Request a new quote; it
  may come from other sources.

## Requesting a higher limit

Your current limit is on the [account API page](https://app.kotova.io/account/api).
To raise it, choose **Request more** next to the rate limit, or **Request higher
limits** under **Access requests**, and pick one of the offered tiers: **500, 1000,
2500 or 5000 units per minute**. Tell us what you are
building: your product, your users, the pairs you need and your expected monthly
volume. Every request is reviewed by hand, and the decision appears on the same page.
You can have one open request per type.

A higher limit belongs to your account, like the default one, so it stays when you
regenerate your key, and the fan-out allowance and the order caps grow with it. For
more than the highest tier, write to [api@kotova.io](mailto:api@kotova.io).

> [!NOTE]
> **Changes to weights**
>
> Request weights change only with at least 30 days' notice, announced in the
> [Changelog](https://docs.kotova.io/x/api/changelog).
