API

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.

View as Markdown

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

BucketLimitApplies to
general600 requests/minuteEvery authenticated request
heavy-query120 requests/minute and 5 concurrentAnalytical reads over a time range
rum-read15 to 30 requests/minute, counted per routeMost reads under /rum/
run20 requests/minuteEvery run:-class endpoint
Per-credential daily ceiling50,000 requests/dayResets at midnight UTC
Per-workspace daily ceiling200,000 requests/dayAcross 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-5c8b2d7e4f10

So 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 limitsAPI credits
CountsRequests per minute or per dayCost per request, weighted by how expensive the work is
Keyed onYour credentialYour workspace
Refused with429 rate-limited402 quota-exhausted
ResetsPer minute, or midnight UTCOn your billing anniversary
Waiting helps?Yes, in seconds or hoursNot 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()

On this page

VitalSentinel

Catch issues before they cost you

Track SEO, performance, and uptime in one place and get alerted the moment something breaks – hours before it hits your traffic.

  • Free plan for 1 domain
  • Set up in minutes
  • No credit card required