Rate Limits
The four rate-limit buckets, the concurrency cap, the two daily ceilings, and how to read the headers to back off before you are refused.
Limits are uniform across every plan, Free included. Protection comes from the shape of the buckets, sized to the cost of the work, rather than from plan gating.
Every bucket except rum-read is keyed on your credential, never on your IP. Your CI runner cannot throttle your production integration, and a shared office network does not put every customer behind it in one bucket.
The buckets
| Bucket | Limit | Applies to |
|---|---|---|
general | 600 requests/minute | Every authenticated request |
heavy-query | 120 requests/minute and 5 concurrent | Analytical reads over a time range |
rum-read | 15 to 30 requests/minute, counted per route | Most reads under /rum/ |
run | 20 requests/minute | Every run:-class endpoint |
| Per-credential daily ceiling | 50,000 requests/day | Resets at midnight UTC |
| Per-workspace daily ceiling | 200,000 requests/day | Across every credential in the workspace |
rum-read is inherited from the RUM handlers the public routes delegate to rather than sized for the public API, so it is counted per route and the budget is not the same on every one: 20 requests per minute on most RUM reads, 30 on /rum/engagement and /rum/pageviews, and 15 on /rum/engagement/correlation. A few RUM reads never reach it at all, among them /rum/overview and /rum/errors. Every one of those budgets sits well under the 120 of heavy-query, so on a /rum/ route this is what stops you first. Plan for 15 requests per minute per route if you want one number that always holds. The X-RateLimit-* headers do not track it, so pace yourself rather than waiting to be warned.
The two daily ceilings are sized to bound a runaway retry loop, not to price the product. No human clicking through a dashboard approaches them; a broken agent loop reaches them in minutes.
Because the workspace ceiling is shared across every credential, a single misbehaving script can throttle every other integration in the same workspace. Both ceilings can be producing 429s for different reasons during the same incident.
Which endpoints are heavy
This is decided per endpoint rather than per module, and the OpenAPI document does not mark it. Roughly a hundred reads are on the heavy-query bucket: most of RUM, Web Analytics, and Search Console, plus a smaller share of CrUX, Synthetic Monitoring, robots.txt, Indexing Monitoring, uptime, and SSL, and GET /domains/{domain_id}/overview.
The safe assumption is that any analytical read over a time range is heavy.
RUM reads sit on heavy-query as well, and where rum-read also applies it is the tighter of the two, so it is the one that refuses them first.
The concurrency cap catches people out
Five heavy reads in flight per credential is a hard ceiling, independent of any per-minute count. Sixty twenty-second scans inside a minute is within the rate limit and still saturates the query pool, so the concurrency cap is what actually bounds it.
A 429 naming heavy-query while X-RateLimit-Remaining still looks healthy means you hit the concurrency cap, not the rate. The headers cannot tell you this in advance, so cap your own worker pool at five concurrent heavy reads and you will never see it.
Reading the headers
Every response carries the state of whichever bucket will refuse you first, not always the general one:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1785836520
X-Request-Id: 3f9a1c22-7b48-4e0d-96a1-5c8b2d7e4f10So X-RateLimit-Limit varies between endpoints, and that is not a bug. It answers "how much budget do I have for this call".
"Tightest" is measured by what is left rather than by the ceiling. Five requests from the end of your general budget, even a heavy read reports the general bucket, because that is what is about to stop you.
X-RateLimit-Reset is an absolute epoch second, not a countdown. Sleep until it, do not sleep for it.
Back off when X-RateLimit-Remaining gets low rather than waiting to be refused. A client that paces itself off the headers never sees a 429 at all.
When you are refused
429, with a Retry-After header in seconds and a body naming the bucket:
{
"type": "https://docs.vitalsentinel.com/api/errors/rate-limited",
"title": "Rate limit exceeded",
"status": 429,
"detail": "Rate limit reached on the heavy-query bucket. Retry after 34 seconds. Narrow the date range or request fewer rows.",
"instance": "/api/public/v1/domains/9c2f4d18-5e6a-4b70-8c19-2d3f5a7b9e04/overview",
"request_id": "3f9a1c22-7b48-4e0d-96a1-5c8b2d7e4f10",
"bucket": "heavy-query",
"retry_after_seconds": 34
}Honor Retry-After.
An integration written before 2026-08-06 may mishandle a RUM refusal. Until then this limit was published as clickhouse-error with no retry interval, so a client built against the old behavior treats it as a transient server fault and retries immediately, straight back into the same window. It is a rate limit, not a database failure. It now reports like any other bucket, with bucket, retry_after_seconds and a Retry-After header, so wait the interval.
The buckets fail open if the cache backing them is unavailable. A burst that "worked once" is not evidence that a limit does not apply. Build the backoff anyway.
Rate limits are not credits
Two independent meters, and confusing them wastes debugging time:
| Rate limits | API credits | |
|---|---|---|
| Counts | Requests per minute or per day | Cost per request, weighted by how expensive the work is |
| Keyed on | Your credential | Your workspace |
| Refused with | 429 rate-limited | 402 quota-exhausted |
| Resets | Per minute, or midnight UTC | On your billing anniversary |
| Waiting helps? | Yes, in seconds or hours | Not until the next cycle |
A cached answer costs zero credits but still consumes a rate-limit slot and still counts against both daily ceilings. That is deliberate: with cache hits free, the daily ceilings are what bound a tight polling loop against a warm cache.
OAuth endpoint limits
The OAuth endpoints run before a credential exists, so they are limited per source IP instead. See Authentication.
A client that paces itself
import time
MAX_CONCURRENT_HEAVY = 5 # The concurrency cap. Headers cannot warn you about this one.
LOW_WATER_MARK = 20 # Slow down before you are refused, not after.
def paced_get(path: str, **params) -> dict:
response = http.get(path, params=params)
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", "5")))
response = http.get(path, params=params)
response.raise_for_status()
remaining = int(response.headers.get("X-RateLimit-Remaining", "1000"))
if remaining < LOW_WATER_MARK:
# Absolute epoch second: sleep until it, not for it.
reset_at = int(response.headers["X-RateLimit-Reset"])
time.sleep(max(0, reset_at - time.time()))
return response.json()