Errors
The RFC 9457 problem format used by the VitalSentinel API, the full error catalog, and what a client should actually do about each response.
Every error is application/problem+json, following RFC 9457. Here is a 403 for a missing scope, byte for byte:
{
"type": "https://docs.vitalsentinel.com/api/errors/insufficient-scope",
"title": "Insufficient scope",
"status": 403,
"detail": "This credential does not have the 'read:overview' scope, which this endpoint requires. Create a new credential with that scope, or ask a workspace admin to grant it.",
"instance": "/api/public/v1/domains/9c2f4d18-5e6a-4b70-8c19-2d3f5a7b9e04/overview",
"request_id": "3f9a1c22-7b48-4e0d-96a1-5c8b2d7e4f10",
"required_scope": "read:overview",
"granted_scopes": ["read:domains"]
}Branch on type, never on the prose in detail. The type URI is stable and is covered by the compatibility promise. detail is written to be acted on and can be reworded at any time.
Several problems carry structured extras alongside the prose, so a client rarely has to parse a sentence:
| Problem | Extra fields |
|---|---|
insufficient-scope | required_scope, granted_scopes |
rate-limited | bucket, retry_after_seconds |
quota-exhausted | used, limit, resets_on |
The OAuth endpoints are the one exception to all of this: POST /oauth/token returns RFC 6749 errors instead. See Authentication.
The catalog
Every type is https://docs.vitalsentinel.com/api/errors/ followed by a suffix. These are the base families:
type suffix | Status | What it means and what to do |
|---|---|---|
invalid-credential | 401 | Missing, malformed, revoked, expired, or wrong-audience credential. Stop and fix the credential. Retrying will not help. |
insufficient-scope | 403 | Authenticated, but this endpoint needs a scope the credential lacks. Mint a new credential with that scope. |
insufficient-role | 403 | The scope was granted, but the creator's workspace role does not allow the operation. A human has to change a membership. A new key will not help. |
not-found | 404 | No such resource, or it exists and this credential cannot see it. Also returned for a path that matches no operation at all. |
method-not-allowed | 405 | The path exists, but not on this HTTP method. |
invalid-request | 400 | Bad parameters, a malformed cursor, mutually exclusive parameters together (a named period sent alongside start_date or end_date, for example), or a missing Idempotency-Key on a run: call. |
conflict | 409 | An Idempotency-Key was reused with a different request body. This is a client bug. |
rate-limited | 429 | A bucket or a daily ceiling is exhausted. Wait for Retry-After. |
quota-exhausted | 402 | A plan quota or the credit allowance is spent. Retrying later today does not help; retrying next billing period might. |
workspace-not-writable | 402 | The workspace subscription is past due, canceled, or expired. Writes are refused exactly as they would be in the dashboard. |
api-disabled | 503 | The public API is temporarily off. Retry after the interval in Retry-After. |
read-only-mode | 503 | Reads are still being served; writes and runs are temporarily disabled. |
credit-balance-unavailable | 503 | The credit balance could not be read, so the request was refused rather than served unmetered. Retry. |
Many operations return a more specific suffix
The table above is not the complete set of strings you will see in type. Individual operations narrow the suffix while keeping the same status, so an exact-match check against that table alone will fall through on real responses.
A 404 from a CrUX endpoint is crux-url-not-found, not not-found. A 401 caused by an IP allowlist is ip-not-allowed, not invalid-credential. There are roughly forty of these.
The specialization is systematic, so you do not need the full list:
| Status | Base suffix | Specific suffixes you may see |
|---|---|---|
| 401 | invalid-credential | missing-credential, invalid-audience, ip-not-allowed, token-revoked, grant-revoked |
| 404 | not-found | domain-not-found, workspace-not-found, crux-not-found, crux-url-not-found, gsc-not-found, gsc-property-not-found, gsc-property-not-linked, synthetic-not-found, synthetic-url-not-found, alert-rule-not-found, alert-history-not-found, notification-not-found, notification-trend-not-found, report-not-found, report-run-not-found, report-template-not-found, report-not-registered, check-data-not-found, and others in the same shape |
| 400 | invalid-request | consent-already-used, csrf-required, workspace-not-accessible |
| 403 | insufficient-scope, insufficient-role | forbidden, plan-restriction, plan-feature-unavailable |
| 409 | conflict | idempotency-key-reuse, report-template-name-conflict |
| 410 | none | report-result-expired, when a stored report result has aged out |
| 500 | none | clickhouse-error, when a RUM query against the analytics store fails |
| 502 | none | upstream-error, upstream-unavailable, storage-unavailable, when a dependency fails |
Until 6 August 2026, a rate-limited RUM read arrived as clickhouse-error instead. The RUM endpoints delegate to handlers that cap reads well below the public ceiling, and their rejection was published as a database failure, with no Retry-After header and no interval to wait for. It is now an ordinary 429 rate-limited on the rum-read bucket, carrying bucket, retry_after_seconds, and the header. An integration written before that date reads it as a transient server fault and retries immediately, straight back into the same window. Move that path onto your 429 handling.
The rule for a client: switch on the HTTP status first, then on the suffix only where you handle a case specifically. An unrecognized suffix should fall back to its status family rather than to an error path.
kind = problem["type"].rsplit("/", 1)[-1]
if response.status_code == 404:
# Every *-not-found narrows this. Treat them all as "not there, or not visible to me".
return None
if response.status_code == 401:
# ip-not-allowed is the one worth naming: the credential is fine, the network is not.
raise BadCredential(hint="check the IP allowlist" if kind == "ip-not-allowed" else None)New suffixes are additive and can appear at any time. See Versioning.
Two 403s that are not the same problem
insufficient-scope and insufficient-role both return 403, and telling them apart is the difference between a client that recovers and one that retries forever.
insufficient-scope | insufficient-role | |
|---|---|---|
| Cause | The credential was never granted that scope | The scope is granted, but the creator's role forbids the operation |
| Fix | Create a new credential with the scope | A workspace owner or admin has to change someone's membership |
| Can your code fix it? | Yes, by asking for a wider grant | No. Surface it to a human and stop |
An agent that cannot distinguish them will keep minting credentials that fail the same way. See Scopes for the underlying rule.
Why 404 and not 403
When a credential's workspace access does not cover a resource that exists, the API returns 404, not 403. The two cases are deliberately indistinguishable.
Confirming that a resource exists but is not yours turns the endpoint into an identifier-guessing oracle. If you are getting an unexpected 404 on an identifier you are sure is right, check GET /me for the workspaces your credential actually reaches.
Handling errors well
def call(method: str, path: str, **kwargs) -> dict:
response = http.request(method, path, **kwargs)
if response.is_success:
return response.json()
problem = response.json()
status = response.status_code
kind = problem.get("type", "").rsplit("/", 1)[-1]
# Transient. Pace off the header, not a fixed sleep.
if status == 429:
time.sleep(problem.get("retry_after_seconds")
or int(response.headers.get("Retry-After", "5")))
return call(method, path, **kwargs)
# Operational, not a contract change. Back off and try again later.
# 502 is a failed dependency and is also worth retrying.
if status == 502 or kind in ("api-disabled", "read-only-mode", "credit-balance-unavailable"):
raise TemporarilyUnavailable(problem, retry_after=response.headers.get("Retry-After"))
# Permanent until a human acts. Never retry these.
# Status first: every *-not-found narrows 404, and 401 has five specific suffixes.
if status in (401, 402) or kind in ("insufficient-scope", "insufficient-role"):
raise NeedsHumanAttention(problem)
# Your bug. Fix the request.
raise ClientError(problem)The three categories that matter:
- Retry with backoff:
rate-limited,api-disabled,read-only-mode. - Never retry, escalate to a human:
invalid-credential,insufficient-scope,insufficient-role,quota-exhausted,workspace-not-writable. - Fix your request:
invalid-request,conflict,method-not-allowed,not-found.
Getting help
Every response carries X-Request-Id, and every problem body repeats it as request_id. Quote it in any support conversation. It is what lets us find the exact request in the access log, and without it a report of "a call failed yesterday" is not actionable.
Endpoint Reference
Every operation in the VitalSentinel public API, grouped by module, with the scope each one requires. 217 operations across 19 groups.
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.