---
title: "Errors"
description: "Error envelope, observed messages, and common error responses."
doc_version: "1"
last_updated: "2026-06-02"
---

<!-- Editors: any error example added anywhere in docs must use the flat {"error":"…"} envelope. Carve-outs (oauth2/*, monitor-test 503) are described below; do not invent nested or RFC 7807 shapes. -->

## Error envelope

The `/api/v1/*` surface uses a flat envelope:

```json
{
  "error": "url is required"
}
```

The `error` field is always a single human-readable string for these endpoints. There is no `code`, `message`, or nested object — clients should match on the HTTP status and (where useful) the literal `error` string.

There are two documented exceptions:

- The `/oauth2/*` endpoints (`/oauth2/register`, `/oauth2/token`, `/oauth2/revoke`) follow RFC 7591 / RFC 6749 and return `{"error":"<machine_code>", "error_description":"<message>"}` (e.g. `{"error":"invalid_client_metadata","error_description":"redirect_uris is required"}`).
- The monitor-test endpoint's kill-switch 503 carries `{error, message}` — see [503 Kill-switch](#503-kill-switch-active-test-monitor-endpoint-only) below.

## Common messages

A representative — not exhaustive — list of the literal `error` strings the API returns today, grouped by HTTP status. New endpoints add new strings; pin clients on the HTTP status first and only fall back to literal-string matching where the wording is part of an established contract (validator output, rate-limit hints).

### 400 Bad Request

| Message | When |
|---------|------|
| `name is required` | Create monitor body missing `name` |
| `url is required` | Create monitor body missing `url` |
| `interval_sec must be at least 60` | `interval_sec` below the 60-second minimum |
| `expected_status must be a single integer between 100 and 599` | `expected_status` non-integer or out of range |
| `type must be one of: down, flapping` | Invalid incident `type` query value |
| `limit must be between 1 and 100` | Pagination `limit` out of range |
| `invalid monitor ID` | Path parameter is non-numeric on monitor endpoints (e.g. `/monitors/abc`) |
| `invalid incident ID` | Path parameter is non-numeric on `/api/v1/incidents/{id}` |
| `invalid key ID` | Path parameter is non-numeric on key endpoints |
| `invalid request body` | Body is not valid JSON |
| `label must be at least 3 characters` | Key label below the minimum length |
| `label must be 100 characters or less` | Key label above the maximum length |
| `missing code` | OAuth callback missing `code` query parameter |
| `invalid or expired code` | OAuth callback `code` rejected by GitHub |
| `url rejected: ip <addr> is in a blocked range` | Target URL resolves to a blocked IP range (SSRF guard) |

### 401 Unauthorized

| Message | When |
|---------|------|
| `missing authorization` | No `Authorization` header |
| `invalid credentials` | API key not recognised or revoked |

### 403 Forbidden

| Message | When |
|---------|------|
| `account suspended; contact support` | Account suspended (e.g. by the port-scan heuristic). Returned on `/api/v1/*`. |
| `forbidden` | Admin-only route hit by a non-admin caller. Opaque on purpose. |

### 404 Not Found

| Message | When |
|---------|------|
| `monitor not found` | Monitor ID is numeric but does not exist on this account |
| `incident not found` | Incident ID is numeric but does not exist on this account |
| `key not found` | API key ID does not exist on this account |

### 409 Conflict

| Message | When |
|---------|------|
| `cannot delete last key` | Tried to delete the only remaining API key |
| `key limit reached` | Account already at the 10-key cap |

### 429 Too Many Requests

Rate-limit responses carry a route-specific message in the flat envelope plus a `Retry-After` header (seconds). Examples:

| Limiter | Message |
|---------|---------|
| Account-wide `/api/v1/*` cap | `account rate limit exceeded; retry in <N>s` |
| Monitor-creation cap | `monitor creation rate limit exceeded; retry in <N>s` |
| Per-monitor manual-test minimum interval | `too many tests for this monitor; retry in <N>s` |
| Per-domain cross-account test cap | `domain rate limit exceeded; retry in <N>s` |

Don't pin clients on a single literal string; honour `Retry-After` and treat any 429 as transient.

### 500 Internal Server Error

The body is `{"error":"internal error"}`. Retry with exponential backoff.

### 503 Kill-switch active (test-monitor endpoint only)

`POST /api/v1/monitors/{id}/test` is the one endpoint where the 503 body deviates from the flat envelope. When the dispatcher's global kill switch is engaged, the test endpoint returns:

```json
{
  "error": "kill_switch_active",
  "message": "check dispatches temporarily disabled"
}
```

with a `Retry-After` header. Both `error` and `message` are present. Other 503s (e.g. `kill switch not configured` on admin endpoints) keep the flat `{"error":"..."}` shape.

## Common scenarios

### SSRF blocked

```json
{
  "error": "url rejected: ip 10.0.0.5 is in a blocked range"
}
```

Private IPs (10.x, 172.16-31.x, 192.168.x), loopback, and link-local addresses are blocked. See [Security](/docs/security/) for details.

### Validation failure

```json
{
  "error": "expected_status must be a single integer between 100 and 599"
}
```

The body is the literal validator output — useful to surface back to the user.