> ## Documentation Index
> Fetch the complete documentation index at: https://docs.use.observer/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and status codes

> The RFC 7807 problem-detail shape every /api/v1 error returns, the title tokens to branch on, and the status codes each endpoint can produce.

Every non-success response from `/api/v1` is an RFC 7807 problem
detail, served with `Content-Type: application/problem+json`:

```json theme={null}
{
  "type": "https://docs.use.observer/api/getting-started/errors#forbidden",
  "title": "forbidden",
  "status": 403,
  "detail": "missing scope: write:incidents"
}
```

| Field | Notes |
| - | - |
| `type` | Link to this page, with the `title` token as the fragment. Each problem below has its own anchor, so the URI opens its description. |
| `title` | Stable machine-readable token. **Branch on this.** |
| `status` | Repeats the HTTP status code. |
| `detail` | Human-readable sentence. Present on most errors. Wording is not part of the contract and may change between releases. |

Some problems carry extra fields, listed with each problem below.
A `429` carries `retry_after` (seconds) and a `Retry-After` header;
wait that long before retrying.

> [!WARNING]
>
> **Match on `title`, not `detail`**
>
> `detail` exists to be read by a person looking at a failed request.
> It interpolates runtime values and is reworded freely. Client logic
> that switches on `detail` will break; logic that switches on
> `title` will not.

## Handling errors

```js JavaScript theme={null}
async function observer(path, init = {}) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(`https://use.observer/api/v1${path}`, {
      ...init,
      headers: { Authorization: `Bearer ${process.env.OBSERVER_API_KEY}`, ...init.headers },
    });
    if (res.ok) return res.status === 204 ? null : res.json();
    const problem = await res.json();
    if (problem.title === "rate_limited") {
      await new Promise((r) => setTimeout(r, (problem.retry_after ?? 1) * 1000));
      continue;
    }
    // Branch on problem.title; show problem.detail to a person.
    throw Object.assign(new Error(problem.detail ?? problem.title), { problem });
  }
  throw new Error("rate limited after 3 attempts");
}
```

## Status codes

| Status | `title` values | When |
| - | - | - |
| `400` | `invalid_json`, `invalid_body`, `invalid_title`, `invalid_initial_message`, `invalid_severity`, `invalid_postmortem_url`, `invalid_type`, `invalid_description`, `invalid_note`, `invalid_status`, `invalid_window`, `missing_window`, `missing_param`, `invalid_range`, `invalid_resolution`, `range_too_large`, `invalid_request`, `invalid_customer_targeting`, `invalid_period`, `invalid_customer_id`, `invalid_state`, `invalid_since`, `invalid_until`, `invalid_action`, `invalid_cursor`, `invalid_name`, `invalid_prometheus_url`, `invalid_invalidate_immediately`, `invalid_delivery`, `invalid_include`, `invalid_target`, `invalid_source`, `invalid_agent_id`, `invalid_source_type`, `unsupported_source_type`, `invalid_probe_config`, `invalid_thresholds`, `invalid_wait_seconds` | The request body or query string is malformed, or a field failed validation. |
| `401` | `unauthenticated` | No bearer token, or the key is invalid or revoked. |
| `403` | `forbidden`, `plan_does_not_permit_api`, `feature_locked`, `quota_exceeded`, `item_suspended_plan` | The key lacks the scope the endpoint requires, the organization's plan does not include the public API or the feature behind the endpoint, a create would go over the plan's limit, or the request changes an item suspended for the plan. |
| `404` | `not_found`, `metric_not_found`, `agent_not_found` | The object does not exist, **or** it belongs to another organization. |
| `409` | `already_published`, `already_resolved`, `already_started`, `already_completed`, `already_canceled`, `canceled`, `not_started`, `not_manual_metric`, `not_config_managed`, `in_use`, `custom_domain_attached`, `note_limit_reached`, `demo_readonly`, `agent_unavailable` | The object is in a state that forbids the requested transition or deletion. |
| `413` | `payload_too_large` | The request body is too large: over 1 MB on `POST /config/apply`, over 64 KB on `POST /incidents/{id}/notes`. |
| `422` | `config_invalid`, `invalid_change_event`, `unknown_services`, `unknown_customers`, `customer_targeting_conflict`, `invalid_page_settings`, `access_mode_console_only` | The body parsed and was structurally valid, but its contents failed domain validation. |
| `429` | `rate_limited`, `queue_full` | The organization's per-minute or daily limit is used up, or a probe test cannot be queued yet. See [Rate limits](https://docs.use.observer/api/getting-started/rate-limits.md). |
| `500` | `internal_error` | Unhandled server error. Safe to retry. |
| `502` | `probe_test_failed` | The agent could not run a probe test. |
| `503` | `unavailable`, `claim_unavailable` | The feature is not available on this deployment yet. |
| `504` | `probe_test_expired` | The agent did not answer a probe test in time. |

`401`, `403`, `429` and `500` can come back from **every** endpoint.
The rest are per-endpoint; each operation page lists the ones it can
return.

### On 404

A missing object and an object owned by another organization return
the identical response. This is deliberate: a distinguishable `403`
would let a caller enumerate which IDs exist in other tenants. Treat
`404` as "not available to this key" rather than "does not exist".
An ID in the path that is not a well-formed uuid gets the same `404`
as an unknown one.

Only endpoints addressing a specific object return `404`. Collection
endpoints such as `GET /services` return `200` with an empty array
when there is nothing to list.

## Success

Successful calls return `200`, including most creates, with these
exceptions:

| Endpoint | Status |
| - | - |
| `POST /agents` | `201` |
| `POST /incidents/{id}/notes` | `201` |
| `POST /metrics/test` | `200` with the result, or `202` when the test is still running on the agent after `wait_seconds` (poll the returned job) |
| `GET /metrics/test/{job_id}` | `202` while the test is pending, `200` once it has a result |

The API does not use `204`. Every `DELETE` returns `200` with a body.

## Rate limiting

Limits are counted per organization, across every API key it owns:
a per-minute limit and a daily quota set by the plan. Responses carry
`X-RateLimit-*` headers for the minute and `X-RateLimit-Daily-*`
headers for the day, and a `429` carries a `Retry-After` header.
Read the ceilings from the headers rather than hard-coding a number,
and back off using `retry_after` rather than a fixed interval. See
[Rate limits](https://docs.use.observer/api/getting-started/rate-limits.md) for the plan table
and every header.

## Problem reference

### `unauthenticated`

`401`. No bearer token was sent, or the key is invalid or revoked.
`detail` says which.

### `forbidden`

`403`. The key is valid but lacks the scope the endpoint requires.
`detail` names the missing scope.

### `plan_does_not_permit_api`

`403`. The organization's plan does not include the public API. The
key stays valid: upgrading restores access without rotating it. When
a plan with API access exists, the body carries an `upgrade` object.

### `feature_locked`

`403`. The organization's plan does not include the feature behind the
endpoint. Returned by:

* the SLA statement endpoints, when the plan does not include the SLA
  Ledger;
* `POST /agents`, when the plan includes no agents. The body also
  carries `quota` and, when a plan with agents exists, `upgrade`;
* `GET /audit-log`, when the plan does not include audit log export
  (Business and Enterprise);
* `GET /incidents/{id}/evidence`, when the plan does not include the
  signed evidence export (Business and up);
* `PATCH /pages/{id}` with `hide_branding: true`, when the plan does
  not include removing Observer branding. Extra field: `errors`, a list
  of `{ path, message }`.

### `quota_exceeded`

`403`. `POST /agents` only: the organization already has as many
agents as its plan allows. Extra fields: `quota` (`capability`,
`used`, `cap`, `plan`) and `upgrade` when a larger plan exists.
Delete an agent that is no longer needed, or upgrade. `GET /me`
reports usage and limits before you try.

### `item_suspended_plan`

`403`. The request changes an item that is suspended because the
organization has more of that kind than its plan allows (after a
downgrade). Nothing was deleted. Returned by `PATCH /pages/{id}` and
`POST /config/apply`. Extra fields, when known: `quota`, `upgrade`,
and `errors` (`{ path, message }`). Choose which items stay active in
the console under billing, or upgrade.

### `rate_limited`

`429`. The per-minute limit or the daily quota is used up, or, on
`POST /metrics/test`, too many probe tests were started. Extra
fields: `retry_after` (seconds), and on the API limits `limit`
(`scope`, `window` of `minute` or `day`, `max`, `plan`) and `upgrade`
when a larger plan exists. See [Rate limits](https://docs.use.observer/api/getting-started/rate-limits.md).

### `queue_full`

`429`. `POST /metrics/test`: the agent already has 20 jobs waiting.
Extra field: `retry_after` (10 seconds), also sent as `Retry-After`.
Retry after that, once the agent has worked through its queue.

### `internal_error`

`500`. An unhandled server error. Safe to retry.

### `unavailable`

`503`. SLA statements are not available yet. Retry later.

### `claim_unavailable`

`503`. `POST /agents` or `POST /agents/{id}/rotate-key` with
`delivery: "claim"`: claim links are not available on this deployment.
Omit `delivery` to get the key in the response.

### `probe_test_failed`

`502`. `POST /metrics/test` or `GET /metrics/test/{job_id}`: the agent
could not run the test. Extra fields: `job_id`, `agent_id`, and
`reason`.

### `probe_test_expired`

`504`. The agent did not answer before the test expired (2 minutes).
Check that it is online and retry. Extra fields: `job_id` and
`agent_id`.

### `not_found`

`404`. The object does not exist or belongs to another organization.

### `metric_not_found`

`404`. The metric named in the path does not exist or belongs to
another organization.

### `agent_not_found`

`404`. `POST /metrics/test`: the `agent_id` does not exist or belongs
to another organization.

### `invalid_json`

`400`. The body could not be read, is not valid JSON, or is not a JSON
object. Every endpoint that takes a body returns this, including
`POST /config/apply`, `POST /change-events` and `PATCH /pages/{id}`.

### `invalid_body`

`400`. `POST /incidents/{id}/notes`: the note's `body` field is
missing or longer than 20,000 characters.

### `invalid_request`

`400`. A request field failed validation. `detail` names the field.

### `invalid_title`

`400`. The `title` is missing or blank when creating an incident or
maintenance, or is not a non-empty string on `PATCH /incidents/{id}`
or `PATCH /maintenances/{id}`.

### `invalid_initial_message`

`400`. `POST /incidents`: `initial_message` is not an object, its
`type` is not one of the message types, or its `description` is not a
string. Nothing is created.

### `invalid_severity`

`400`. `severity` is not one of the accepted values.

### `invalid_postmortem_url`

`400`. `postmortem_url` is not an http or https URL.

### `invalid_type`

`400`. A message `type` is not one of the accepted values.

### `invalid_description`

`400`. A message `description` is missing or invalid, or an agent
`description` on `POST /agents` is longer than 500 characters.

### `invalid_note`

`400`. The `note` on a manual metric status is neither a string nor
`null`.

### `invalid_status`

`400`. A `status` value is not one of the accepted values: the
`status` filter on `GET /metrics` (`healthy`, `degraded`, `unhealthy`,
`no_data`, `unknown`), the status set on a manual metric (`healthy`,
`degraded`, `unhealthy`, `no_data`), or the `status` on an incident
draft request.

### `invalid_window`

`400`. A time window parameter is malformed or out of range.

### `missing_window`

`400`. A required time window is missing.

### `missing_param`

`400`. A required query parameter is missing.

### `invalid_range`

`400`. The requested time range is malformed, or its end is before its
start.

### `invalid_resolution`

`400`. The requested history resolution is not supported.

### `range_too_large`

`400`. The requested time range exceeds the maximum for the endpoint.

### `invalid_period`

`400`. `period` on `GET /sla/statements` must be `YYYY-MM`.

### `invalid_customer_id`

`400`. `customer_id` on `GET /sla/statements` must be a uuid.

### `invalid_state`

`400`. A `state` filter is not one of the accepted values: on
`GET /incidents` and `GET /maintenances` it must be `open`,
`resolved` or `all`; on `GET /sla/statements` it must be a statement
state.

### `invalid_since`

`400`. `since` on `GET /incidents`, `GET /maintenances` or
`GET /audit-log` is not an
ISO 8601 date (`2026-01-31`) or date-time (`2026-01-31T12:00:00Z`),
or names an impossible date or time. A date-time without an offset is
read as UTC.

### `invalid_until`

`400`. `until` on `GET /audit-log` is not an ISO 8601 date or
date-time.

### `invalid_action`

`400`. `action` on `GET /audit-log` is not an audit action such as
`page.access_changed`, or a prefix ending in `.`.

### `invalid_cursor`

`400`. `cursor` on a list endpoint is not a `next_cursor` value that
endpoint returned. Omit `cursor` to start from the first page.

### `invalid_name`

`400`. `POST /agents`: `name` is missing, empty, or longer than 120
characters.

### `invalid_prometheus_url`

`400`. `POST /agents`: `prometheus_url` is not an http or https URL.

### `invalid_invalidate_immediately`

`400`. `POST /agents/{id}/rotate-key`: `invalidate_immediately` is
not a boolean.

### `invalid_delivery`

`400`. `POST /agents` or `POST /agents/{id}/rotate-key`: `delivery`
is not `plaintext` or `claim`.

### `invalid_include`

`400`. `include` on an agent read is not `install`.

### `invalid_target`

`400`. `target` on an agent read with `include=install` is not one of
the install targets (`docker`, `compose`, `kubernetes`, `systemd`,
`launchd`, `binary`).

### `invalid_source`

`400`. `POST /incidents/{id}/notes`: `source` (the tool that produced
the note) is missing or longer than 80 characters.

### `invalid_agent_id`

`400`. `POST /metrics/test`: `agent_id` is not an agent's uuid.

### `invalid_source_type`

`400`. `POST /metrics/test`: `source_type` is missing.

### `unsupported_source_type`

`400`. `POST /metrics/test`: the source type cannot be tested.

### `invalid_probe_config`

`400`. `POST /metrics/test`: `source_config` is not an object, or
fails validation for its source type. Extra field, when the config
failed validation: `errors`, a list of `{ path, message }`.

### `invalid_thresholds`

`400`. `POST /metrics/test`: `thresholds` is not an object with
`healthy_operation`, `healthy_value`, `unhealthy_operation` and
`unhealthy_value`.

### `invalid_wait_seconds`

`400`. `POST /metrics/test`: `wait_seconds` is not an integer from 0
to 20.

### `invalid_customer_targeting`

`400`. `customer_targeting` is malformed: an unknown `mode`,
`customers` missing or empty for `selected`, `customers` present for
`none` or `all`, more than 500 entries, or an unknown field. Extra
field: `errors`, a list of `{ path, message }`.

### `already_published`

`409`. The incident is already published.

### `already_resolved`

`409`. The incident is already resolved.

### `already_started`

`409`. The maintenance has already started.

### `already_completed`

`409`. The maintenance has already completed.

### `already_canceled`

`409`. The maintenance has already been canceled.

### `canceled`

`409`. The maintenance was canceled, so it cannot start or complete.

### `not_started`

`409`. The maintenance has not started yet, so it cannot complete.

### `not_manual_metric`

`409`. The metric is agent-driven; only manual metrics accept a
status through the API.

### `not_config_managed`

`409`. Per-object `DELETE` on a metric, service, SLO, or status page
that was made in the console. The API deletes only objects that config
apply manages (`managed_by: config`). Delete it in the console, or
apply a document that names it first, which adopts it.

### `in_use`

`409`. Per-object `DELETE` on an object that others depend on: SLOs
on a metric or service, status pages showing a metric, or active
subscribers of a page. Deleting it would delete or detach them too.
Extra field: `referenced_by`, which lists the dependents (`slos` and
`pages` as `{ id, config_key, name }`, and `subscribers` as a count).
Remove the references first, or repeat the request with
`cascade=true`.

### `custom_domain_attached`

`409`. `DELETE /pages/{id}`: the page has a custom domain. Remove the
domain in the console first.

### `note_limit_reached`

`409`. `POST /incidents/{id}/notes`: the incident already holds 500
notes.

### `demo_readonly`

`409`. `PATCH /pages/{id}` on a demo page. Demo data is read-only:
remove it in the console, then create your own.

### `agent_unavailable`

`409`. `POST /metrics/test`: no agent can run the test right now. The
agent is offline, has never connected, is older than 1.7.0, or has a
revoked key, or no `agent_id` was given for a source only an agent can
run. Extra fields: `reason`, `agent_id`, `min_agent_version`, and
`private_url: true` when the target is not public.

### `payload_too_large`

`413`. The request body is too large: the config document on
`POST /config/apply` exceeds 1 MB, or the request on
`POST /incidents/{id}/notes` exceeds 64 KB.

### `config_invalid`

`422`. The config document parsed but failed validation. Nothing is
applied. Extra fields: `errors`, a list of `{ path, message }`, and
`warnings`, in the same shape (see `POST /config/apply`).

### `invalid_page_settings`

`422`. `PATCH /pages/{id}`: the page settings failed validation, or
name a theme preset that does not exist. Extra field: `errors`, a
list of `{ path, message }`.

### `access_mode_console_only`

`422`. `PATCH /pages/{id}`: `access_mode` can only be switched to
`public` through the API. Other modes need credentials or an
allow-list; set them in the page editor in the console. Extra field:
`errors`.

### `invalid_change_event`

`422`. The change event failed validation: for example `occurred_at`
is not a valid timestamp, or `service_id` is not a service's uuid.
`detail` names the problem.

### `unknown_services`

`422`. An `affected_services` value on `POST /incidents`,
`PATCH /incidents/{id}`, `POST /maintenances` or
`PATCH /maintenances/{id}` matches no service in your organization.
Nothing is written. Extra field: `unknown_services`, which echoes only
the values you sent that did not match. A service of another
organization is reported the same way as one that does not exist.

### `unknown_customers`

`422`. A `customer_targeting.customers` or `visible_to_customer_ids`
value matches no customer in your organization. Extra field:
`unknown_customers`, which echoes only the values you sent that did
not match. A customer of another organization is reported the same
way as a value that does not exist.

### `customer_targeting_conflict`

`422`. The update is customer-scoped (`visible_to_customer_ids` is
set), so `customer_targeting` must be `selected` with exactly those
customers. Omit `customer_targeting` to keep the two in sync
automatically.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.