Rate limits
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
Section titled “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 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 shows
the units used in the current minute and the requests of the last seven days.
Endpoint weights
Section titled “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
Section titled “What a request costs”A keyed request costs its weight whatever it answers, errors included: a quote
answered with 422 amount_below_min, for example,
still costs one unit. The exceptions:
- Refused by the limit. A request refused with
429rate_limitedcosts nothing. ItsX-Kotova-Weightheader reports what it would have cost. - Refused before the limit is checked. These cost nothing and carry no rate-limit
headers: every authentication failure (
401unauthorized,401key_revoked,403affiliate_inactive,429rate_limitedwithdetails.reason: "auth_failures"), and an unknown or repeated query parameter (400invalid_request). - A quote refused by the fan-out allowance.
429rate_limitedwithdetails.reason: "fanout"costs nothing (see Quotes that ask the sources). - Creating an order that fails before a source is contacted.
POST /orderscosts 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 /ordersthat repeats an earlier request with the sameIdempotency-Keyand body answers that order (Idempotent-Replayed: true) and costs 1.
Units given back go into the window the request was charged in.
Response headers
Section titled “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/2 200X-Request-Id: req_8fK2mQ9xLp3vTz7nWc1bYd4hX-Kotova-Weight: 1RateLimit-Policy: "units";q=250;w=60RateLimit: "units";r=187;t=34X-RateLimit-Limit: 250X-RateLimit-Remaining: 187X-RateLimit-Reset: 34X-Kotova-Quote-Cache: missKeyless responses carry no rate-limit headers, because they cost no units.
When you reach the limit
Section titled “When you reach the limit”A request that does not fit is answered 429 with Retry-After:
{ "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.
# -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"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;}import osimport timeimport 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.
Quotes that ask the sources
Section titled “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.
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
Section titled “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, 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
Section titled “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
Section titled “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_skippedwarning. When no source gave a usable quote and at least one could not be asked right now,POST /quoteanswers503sources_unavailablewithRetry-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
422no_source_availableand the reasoncreate_budget_exhausted. - When the source of your quote cannot take the order,
POST /ordersplaces it at the next of the quote’s fallback sources that can (see Next best source). When none of them is available, or withretry_next_best_source: false, it answers503source_unavailable. Request a new quote; it may come from other sources.
Requesting a higher limit
Section titled “Requesting a higher limit”Your current limit is on the account API page. 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.