Rate limits
To keep the Kavitro API fast and fair for everyone, we apply limits in two directions: how many requests you can make in a given time (request rate limits and mutation rate limits), and how much any single request may ask for (its cost, size and depth). This page explains each, how to see where you stand, and how to handle a request that goes over.
Request rate limits
The number of requests you can make against the GraphQL endpoint in a one-minute window depends on whether the request is authenticated:
| Requests | Per minute | Counted by |
|---|---|---|
| Authenticated (with an access token) | 500 | Each access token |
| Unauthenticated | 60 | Each IP address |
Every access token has its own independent budget, so separate integrations using separate tokens never compete for the same limit. This is one more reason to create a dedicated access token for each integration rather than sharing one.
These are the limits we currently enforce, and they may be adjusted over time as we tune the API for fair use. Read the response headers (below) rather than hard-coding the numbers into your application.
Checking your usage
Every response to an executed query includes headers that tell you where you stand in the current window:
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 483
X-RateLimit-Limit- your ceiling for the current window.X-RateLimit-Remaining- how many requests you have left before you're throttled.
When you exceed the limit
If you go over your limit, the request is rejected with an HTTP 429 Too Many Requests status and a Retry-After header telling you how many seconds to wait before trying again:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
{
"message": "Too Many Requests."
}
This is one of the few cases where the API does not follow the usual GraphQL convention of always returning HTTP 200 with an errors property (see error handling). Because throttling happens before your query is executed, the response is a plain HTTP 429 with a short JSON body - not the usual { "data": ..., "errors": ... } shape. Make sure your client handles it as an HTTP-level error. An oversized query is rejected the same way.
Note that the mutation limits below also return 429, but do carry the usual errors shape. So on a 429, check the body: an errors array means you met a mutation limit, and its absence means you met the request limit.
Best practices
- Honor
Retry-After. When you get a429, wait the number of seconds it specifies before retrying, ideally with exponential backoff. - Watch
X-RateLimit-Remaining. Slow down on your side before you reach zero instead of waiting for the429. - Reuse your access token. A token from
loginstays valid for three days. Re-authenticating on a short timer burns requests on nothing but logins. See storing and reusing tokens. - Use a token per integration. Because each token has its own budget, splitting your integrations across tokens gives each one its full limit.
- Prefer webhooks over polling. If you're polling for changes, switch to webhooks to have updates pushed to you and spend far fewer requests. See polling vs webhooks.
Mutation rate limits
Mutations are limited separately from, and in addition to, the request limits above. A query is answered by the API alone, but a mutation also schedules background work - notifications, search indexing, webhooks - so the two are not equally cheap and are not counted the same way.
| Mutations | Per minute | Counted by |
|---|---|---|
| Per user | 40 | The user |
| Per account | 300 | The whole account |
| Unauthenticated | 40 | Each IP address |
Both authenticated limits apply at once, so a request is rejected if either the user's budget or the account's is exhausted. Everyone in an account shares the account budget.
What counts as one mutation
Not the request, and not the operation - each root mutation field you ask for. This is the field directly inside mutation { ... }, and it counts once per field regardless of how you package it:
# costs 3, not 1
mutation {
first: updateContract(id: 1, input: $a) { id }
second: updateContract(id: 2, input: $b) { id }
third: updateContract(id: 3, input: $c) { id }
}
- Sending several operations in one HTTP request costs the sum of their mutation fields, not 1.
- Aliasing the same field several times in one document costs one per alias, as above.
- Fields reached through a fragment are counted too.
- Queries cost nothing. Only mutations consume this budget, so read traffic is governed purely by the request limits above.
In normal use a request carries a single mutation and costs 1.
Checking your usage
Every response to a request that ran at least one mutation carries its own headers, separate from the request-limit ones so you can read both at once:
X-RateLimit-Mutation-Limit: 40
X-RateLimit-Mutation-Remaining: 33
X-RateLimit-Mutation-Account-Limit: 300
X-RateLimit-Mutation-Account-Remaining: 271
The account pair only appears for authenticated requests. Both pairs are also returned on a rejection, which is what tells you whose budget ran out: a 429 showing X-RateLimit-Mutation-Remaining: 40 beside X-RateLimit-Mutation-Account-Remaining: 0 means your own budget is untouched and colleagues on the same account have spent it - so waiting is the right response, not slowing yourself down.
When you exceed the limit
Unlike the request limit, a mutation limit returns HTTP 429 with the usual GraphQL errors shape, so you can read extensions.category:
{
"errors": [
{
"message": "Too many mutations. This request runs 5 of them and would pass the limit of 40 per minute. Try again in 37 seconds, or send fewer mutations per request.",
"extensions": {
"category": "TOO_MANY_MUTATIONS"
}
}
]
}
Two different situations share that status, and only one is worth waiting out:
- Your window is full. The response carries a
Retry-Afterheader. Wait that many seconds and the same request will succeed. - One request asked for more mutations than a whole minute allows. Waiting can never help, so there is no
Retry-Afterand the message tells you to split the request instead.X-RateLimit-Mutation-Remainingmay still show budget available for a smaller one.
Treat the absence of Retry-After as "change the request", not "retry later".
Best practices
- One mutation per request. It keeps the accounting predictable and gives you a clean error when a single change fails.
- Pace bulk work. If you are importing or updating in bulk, spread the writes rather than firing them as fast as you can, and honour
Retry-Afterwhen it appears. - Watch
X-RateLimit-Mutation-Remainingand slow down before you reach zero. - Remember the account budget is shared. A bulk job competes with everyone else signed in to the same account; run large imports outside business hours where you can.
Query cost
A single GraphQL query can ask for a lot of data at once - many fields, large lists, deep nesting - so request count alone isn't enough to keep the API responsive. Every query is also assigned a complexity score, and queries that are too expensive are rejected before they run.
You don't have to calculate this yourself, but it helps to understand what drives the score:
- Each field you request adds to the cost. The more fields, the higher the score.
- Lists multiply. For a paginated field, the cost of everything you select inside it is multiplied by the number of records you request. Asking for
first: 50is far more expensive thanfirst: 5selecting the same fields. - Nesting compounds. A list inside a list multiplies again, so deeply nested lists grow the score quickly.
For example, this query fetches 25 records and selects three fields on each:
query {
contacts(first: 25) {
edges {
node {
id
name
lastName
}
}
}
}
Its cost is driven by 25 × (the fields selected per record), plus a little for the list itself. Select more fields, raise first, or nest another list inside, and the score climbs.
Checking a query's cost
You never have to guess: the computed score is returned with every executed query, under extensions.complexity:
{
"data": { ... },
"extensions": {
"complexity": 320
}
}
Use extensions.complexity while you build. Run your query, look at the score, and tune the fields and page sizes until it sits comfortably low. This is the reliable way to know how expensive a query is - much better than estimating.
When a query is too expensive
If a query's complexity exceeds the allowed ceiling, it's rejected before it executes and returns a GraphQL error in the errors array, with no data. Unlike the rate-limit 429, this is a normal HTTP 200 response that follows the usual error-handling convention.
The exact ceiling can change as we tune the API, so don't hard-code a threshold. Instead, keep an eye on extensions.complexity, keep your queries lean, and handle a rejected query gracefully.
Reducing query cost
If a query is rejected, or its score is higher than you'd like:
- Request only the fields you actually use. Every field counts.
- Use smaller pages. Lower
firstand page through the results instead of pulling everything in one query. Most lists cap out at 50 records per page, but default page sizes differ from field to field - passfirstexplicitly rather than relying on the default. See pagination. - Avoid deep nesting. Split a deeply nested query into a few smaller, focused queries.
Query size
Separately from cost, the text of your query is measured before it is parsed, and a document larger than 8 KB (8,192 bytes) is rejected.
Because that check runs before parsing, an oversized document is turned away outright: no complexity score comes back and no cost or depth error is raised, only the size error below.
In practice this is a generous ceiling. A readable query selecting a few dozen fields is comfortably under 1 KB; you are unlikely to meet this limit unless a query is being generated programmatically.
What counts toward it
Only the GraphQL document itself - the text of your query or mutation.
- Variables aren't part of the document. Only the query or mutation text is measured, so the values you pass as variables don't add to its size.
- Batched operations are added together. If you send several operations in a single HTTP request, the limit applies to their combined text.
- Uploaded files don't count. The 8 KB document limit still applies to a file upload, but the document travels inside the multipart
operationsfield alongside its variables, and that whole field has a separate, larger ceiling of 80 KB (81,920 bytes). The bytes of the uploaded file itself are never counted against any of these limits.
When a query is too large
The request is rejected with HTTP 413 Payload Too Large:
{
"errors": [
{
"message": "The query is too large. Keep it under 8192 bytes, or split it into smaller requests.",
"extensions": {
"category": "QUERY_TOO_LARGE"
}
}
]
}
Three related limits guard multipart file uploads, each with its own category:
category | Limit | Applies to |
|---|---|---|
OPERATIONS_TOO_LARGE | 80 KB | The multipart operations field. |
MAP_TOO_LARGE | 80 KB | The multipart map field. |
BODY_TOO_LARGE | 1 MB | The raw request body, excluding uploaded files. |
OPERATIONS_TOO_LARGE usually means the variables are large rather than the query, so check what you are sending alongside the file. MAP_TOO_LARGE points at an upload with a very large number of file entries.
Like the rate-limit 429, this is an HTTP-level rejection: the status is 413, not the usual 200. The body still uses the normal errors shape, so you can read extensions.category from it - but check the status code as well, rather than only inspecting the body.
A 413 is a permanent rejection for the request as sent - retrying it unchanged will always fail. Reduce the query below the limit before sending it again.
As with the other limits, don't hard-code the threshold - read the byte figure from the error message, and keep your queries comfortably below it.
Staying under it
- Pass data in variables rather than inlining it into the query text, so large or dynamic values don't inflate the query itself. This is the single most effective step.
- Split large batches into separate requests, or into fewer operations per request.
- Request only the fields you use. This lowers both the size and the cost.
- If you generate queries programmatically, strip comments and redundant whitespace before sending. Generated documents are the usual reason this limit is met at all.
Query depth
Separately from complexity, queries are limited to a maximum nesting depth of 11 levels. A query nested deeper than that is rejected with a GraphQL error. In practice this only affects unusually deep queries; if you hit it, flatten the query or split it into smaller ones.
Introspection
Schema introspection is disabled. To explore the available types, queries, mutations and fields, use the API Reference, which is generated directly from the current schema.
For the full set of errors the API can return, and how to tell them apart, see error handling.