Rate limits
Requests are limited per API key, not per workspace or per user. Two keys in the same workspace hold independent budgets, so a backfill running under one key cannot throttle a dashboard running under another. This is the main reason to create one key per integration.
| Limit | Value |
|---|---|
| Requests per minute, per key (reads) | 300 |
| Requests per minute, per key (writes) | 60 |
| Window | Sliding, 60 seconds |
The window slides rather than resetting on the minute, so there is no benefit to timing requests to a clock boundary.
Reading your budget
Every response carries your current standing, so you never have to guess or wait for a rejection to find out.
RateLimit-Limit: 300
RateLimit-Remaining: 287
RateLimit-Reset: 41| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed in the window |
RateLimit-Remaining | Requests left right now |
RateLimit-Reset | Seconds until the window frees up |
A well-behaved client reads RateLimit-Remaining and slows down as it approaches zero, rather than sprinting into a 429.
When you exceed it
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Retry after 12 seconds.",
"request_id": "req_01J8Z3KB77"
}
}The response is 429 and includes a Retry-After header in seconds. Wait at least that long. Retrying sooner consumes budget without succeeding and extends the period you are throttled.
Retrying correctly
Use exponential backoff with jitter. Without jitter, every worker that hit the limit at the same moment retries at the same moment and you throttle yourselves again.
async function request(url, key, attempt = 0) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } })
if (res.status === 429 && attempt < 5) {
const retryAfter = Number(res.headers.get('Retry-After') ?? 1)
const jitter = Math.random() * 0.3 * retryAfter
await new Promise(r => setTimeout(r, (retryAfter + jitter) * 1000))
return request(url, key, attempt + 1)
}
return res
}Retry 429 and 5xx. Never retry 4xx other than 429: the request will fail identically every time, and the only thing a retry loop achieves is burning your budget. See error handling.
Staying inside the limit
Request larger pages. limit=100 fetches the same data in a fifth of the requests that limit=20 needs. See pagination.
Filter server-side. Narrowing by date range, topic, or engine in the query is free. Fetching everything and filtering in your own code costs requests you did not need to spend.
Do not poll faster than the data changes. Asky data refreshes on a pipeline cadence measured in hours. Polling every minute returns the same numbers roughly sixty times over. See caching and freshness.
Sync incrementally. Pull the window you need rather than re-fetching all history on every run.
Serialise your workers. Parallel workers sharing a key share its budget. If you fan out, cap concurrency in your own code.
Concurrency
There is no separate concurrency limit today: the per-minute budget is the only ceiling.
Keeping parallelism in the low single digits per key is still the better strategy. Fanning out wider spends the same budget faster and then stalls, so it is rarely quicker overall, and a limit may be added if it turns out to be needed.
If the limit does not fit
The limit suits scheduled syncs, dashboards, and normal automation comfortably. If you have a genuine need for more, such as a large historical backfill or a high-frequency internal dashboard, contact us and describe the pattern. A bulk export is often a better answer than a higher request ceiling.
These numbers can change
Limits may be adjusted as we learn what real integrations need. Read the RateLimit-* headers rather than hardcoding the numbers above, and they will stay correct. If the ceiling does not fit what you are building, contact us: raising it for a workspace is a configuration change, not a release.