# Errors



Every error is `application/problem+json`, following RFC 9457. Here is a `403` for a missing scope, byte for byte:

```json
{
  "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"]
}
```

<Callout type="warning">
  **Branch on `type`, never on the prose in `detail`.** The `type` URI is stable and is covered by the [compatibility promise](/api/versioning). `detail` is written to be acted on and can be reworded at any time.
</Callout>

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](/api/authentication#error-format-on-oauth-endpoints).

## The catalog [#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. &#x2A;*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. &#x2A;*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. &#x2A;*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 [#many-operations-return-a-more-specific-suffix]

<Callout type="warning">
  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.
</Callout>

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                                                                                                                                                                                                                                                                                                                                                                                       |

<Callout type="warning">
  **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](/api/rate-limits), 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.
</Callout>

**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.

```python
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](/api/versioning#what-counts-as-a-breaking-change).

## Two `403`s that are not the same problem [#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](/api/scopes#a-credential-is-a-ceiling-not-a-grant) for the underlying rule.

## Why `404` and not `403` [#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 [#handling-errors-well]

```python
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:

1. **Retry with backoff:** `rate-limited`, `api-disabled`, `read-only-mode`.
2. **Never retry, escalate to a human:** `invalid-credential`, `insufficient-scope`, `insufficient-role`, `quota-exhausted`, `workspace-not-writable`.
3. **Fix your request:** `invalid-request`, `conflict`, `method-not-allowed`, `not-found`.

## Getting help [#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.
