# Farsight API Everything the dashboard can do, the API can do. This file is the contract; `/api/openapi.json` describes the same routes for tools, and `/llms.txt` is the short version for AI agents. ## Basics - Base URL: `https:///api/v1`. JSON in, JSON out (`Content-Type: application/json`). - **Who you are:** - Scripts and agents: an API key in `Authorization: Bearer fs_<48 hex>`. Make keys in Settings, Security, API keys (or `POST /keys`). Scopes: `read` < `write` < `admin`. - The dashboard: the `farsight_session` cookie (HttpOnly, Secure, SameSite=Strict). Cookie requests that change something must send `Content-Type: application/json` and an `Origin` header equal to the dashboard's origin. - **Scopes:** `read` sees everything (secrets are always masked). `write` changes monitors, incidents, the status page and channel settings without secrets. `admin` also handles channel secrets, settings, import with secrets and API keys. Keys can never manage sign-in factors (passwords, passkeys, the authenticator, recovery codes, sessions). - **Step-up:** with a session, changing security settings, alert channels, deleting monitors and exporting need a sign-in within the last 12 hours. Otherwise the answer is `403 {"error":{"code":"step_up"}}`: confirm with `/auth/step-up/...` and repeat the request. API keys are not asked to step up (their scope is the gate). - **Times** are RFC 3339 UTC strings (`2026-10-04T01:15:00.000Z`) unless a field ends in `_ms` (epoch milliseconds, used in chart series). Durations end in `_ms` or `_s`. - **Errors** always look like: ```json {"error": {"code": "invalid", "message": "http.url: 'x' is not a valid URL", "field": "http.url", "fields": [{"field": "http.url", "message": "'x' is not a valid URL"}]}} ``` Codes: `bad_request` 400, `sign_in` 401, `forbidden` 403, `step_up` 403, `not_found` 404, `conflict` 409, `invalid` 422 (with `field`/`fields`), `too_many_requests` 429 (with `Retry-After`), `internal` 500, `unavailable` 503. - **Lists** are `{"items": [...], "next_cursor": }`; pass `?before=` for the next page and `?limit=` (default 50, max 500). - **Idempotency:** send `Idempotency-Key: ` on a POST and a retry with the same key and body within 24 hours returns the first answer without doing the work twice. - **Rate limits:** 600 requests a minute per API key; sign-in routes are much stricter (see Sign-in). ## Monitors A monitor is a **kind** plus that kind's settings under a key of the same name, plus common settings: ```json { "name": "Pesa API", "kind": "http", "http": {"url": "https://api.example.com/health", "expect_status": "200-299", "json": [{"path": "$.ok", "op": "==", "value": true}]}, "group": "Mumbai", "tags": ["api", "pesa"], "interval_s": 30, "public": true, "public_name": "Pesa API", "public_group": "APIs" } ``` ### Common settings | Field | Default | Rules | Meaning | |---|---|---|---| | `name` | required | 1-100 chars | What you call it | | `slug` | from `name` | `a-z0-9-`, up to 60, unique | Stable handle for agents and imports | | `kind` | required | `http` `tcp` `ping` `dns` `tls` `heartbeat` | What kind of check | | `group` | null | up to 60 | Groups the dashboard list | | `tags` | `[]` | up to 20, each 1-30 | Free labels | | `description` | null | up to 500 | Shown in alerts | | `runbook_url` | null | http(s) URL | Where the fix is written down; linked in alerts | | `interval_s` | 60 | 10-86400 (heartbeat 30-2592000) | Time between checks | | `timeout_ms` | 10000 | 500-60000, not above the interval | Whole-check deadline | | `confirm_after` | 2 | 1-10 | Failed checks in a row before Down | | `retry_interval_s` | 10 | 5 to the interval | Re-check delay after a failure | | `recover_after` | 1 | 1-10 | Good checks in a row before Up | | `slow_ms` | null | 1-60000, 0 or null = off | A good check slower than this is Slow | | `slow_after` | 3 | 1-20 | Slow checks in a row before the monitor shows Slow | | `alert_on_slow` | false | | Send alerts for Slow | | `channels` | null | channel ids, or null | Who hears about it; null = every enabled channel, `[]` = nobody | | `remind_every_min` | 0 | 0 or 5-10080 | While Down, remind this often (0 = never) | | `paused` | false | | No checks, no alerts | | `muted_until` | null | RFC 3339 or null | Checks run, alerts are held back until then | | `public` | false | | Shown on the status page | | `public_name` | null | up to 100 | Name on the status page (defaults to `name`) | | `public_group` | null | up to 60 | Section on the status page (defaults to `group`) | | `sort` | 0 | integer | Order within a group (low first) | Read-only fields in answers: `id`, `target` (a one-line description), `created_at`, `updated_at`, `state`, `stats`. ### Kinds **`http`** | Field | Default | Meaning | |---|---|---| | `url` | required | `http://` or `https://`. Credentials in the URL are moved into `auth` | | `method` | `GET` | GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS | | `headers` | `[]` | `[{"name", "value", "secret": false}]`; a `secret` header's value is masked in answers | | `body`, `content_type` | null | Request body (up to 64 KB) and its type | | `auth` | null | `{"type": "basic", "username", "password"}` or `{"type": "bearer", "token"}` (secrets masked in answers) | | `follow_redirects` | true | Up to 5 hops | | `ip_version` | `auto` | `auto`, `ipv4`, `ipv6` | | `resolve_to` | null | Connect to this IP but keep the URL's host (check an origin behind a CDN) | | `tls_verify` | true | false accepts self-signed or invalid certificates | | `tls_warn_days` | 14 | Warn at 14, 7, 3 and 1 days before expiry (0 = off) | | `expect_status` | `200-399` | Codes and ranges, e.g. `200-299,401` | | `body_contains`, `body_not_contains` | null | Text that must (not) appear in the first 1 MB | | `json` | `[]` | Up to 20 rules `{"path": "$.a.b[0]", "op": "==", "value": ...}`; ops `==` `!=` `<` `<=` `>` `>=` `contains` `exists` `not_exists` | | `expect_header` | null | `{"name": "content-type", "contains": "json"}` | | `max_ms` | null | Slower than this fails the check (unlike `slow_ms`) | **`tcp`**: `host`, `port`, optional `send` (text sent after connecting), `expect_banner` (the first line must start with it, e.g. `SSH-2.0`), `ip_version`. **`ping`**: `host`, `packets` (1-5, default 3; passes if any comes back), optional `max_loss_pct`, `ip_version`. **`dns`**: `name`, `record` (`A` `AAAA` `CNAME` `MX` `TXT` `NS`, default `A`), `resolver` (IP, optional `:port`, default `1.1.1.1`), optional `expect` (some answer must contain this text). **`tls`**: `host`, `port` (default 443), optional `sni`, `tls_warn_days` (default 14). **`heartbeat`**: `grace_s` (default 60). Your job calls the monitor's secret URL every `interval_s`; if nothing arrives within `interval_s + grace_s`, it goes Down. Get the URL with `POST /monitors/{id}/heartbeat-token` (shown once). Secret values come back as `"********"`; sending `"********"` back keeps the stored value, **but only while the URL keeps its scheme, host and port** (and `resolve_to`). Point a monitor somewhere else and the secrets must be entered again, so a write key can never redirect stored credentials. Headers named like credentials (`Authorization`, `Cookie`, `X-Api-Key`, anything with `token`, `key`, `secret` or `password`) are always treated as secret. ### State and stats in answers ```json "state": { "status": "up", // up | down | slow | paused | unknown "since": "2026-10-04T01:15:00.000Z", "failing": 0, // failed checks so far, before Down is confirmed "flapping": false, "muted": false, "last_check_at": "...", "last_ok_at": "...", "latency_ms": 182, "error": null, // or {"code": "timeout", "message": "no answer in 10 s (waiting for headers)"} "incident_id": null, "cert_expires_at": "2026-12-01T00:00:00.000Z", "note": null // why the latest check was not counted: Farsight offline, or a network path problem }, "stats": { "uptime_24h": 100.0, "uptime_7d": 99.98, "uptime_30d": 99.95, // percent, null when no data "p50_ms_24h": 180, "p95_ms_24h": 240, "spark": [{"t_ms": 1791000000000, "avg_ms": 181, "worst": "up"}, ...] // 24 hourly points, oldest first; worst is up | slow | down | unknown | null } ``` ### Routes | Method and path | Scope | What it does | |---|---|---| | `GET /monitors` | read | All monitors with state and stats. Filters: `status`, `group`, `tag`, `kind`, `q` (name, slug, target) | | `GET /monitors/{id}` | read | One monitor (`{id}` may also be `slug:`) | | `POST /monitors` | write | Create. 201 with the monitor. A heartbeat monitor's answer includes `heartbeat_url` once | | `PATCH /monitors/{id}` | write | Change only the fields sent (kind settings merge field by field; `null` clears) | | `PUT /monitors/by-slug/{slug}` | write | Create or replace by slug (idempotent; what agents should use) | | `DELETE /monitors/{id}` | write (+ step-up) | Delete with its history | | `POST /monitors/{id}/pause`, `/resume` | write | Stop or restart checks | | `POST /monitors/{id}/mute` | write | Body `{"until": ""}` or `{"minutes": 60}` | | `POST /monitors/{id}/unmute` | write | | | `POST /monitors/{id}/check` | write | Check now; answers with the result (also fed to the state machine) | | `POST /monitors/test` | write | Body: a monitor (unsaved). Runs one check and returns the result; changes nothing | | `POST /monitors/bulk` | write | `{"monitors": [...], "dry_run": false}`; creates or updates each by `slug`. Answers `{"created": [slugs], "updated": [...], "unchanged": [...], "errors": [{"slug", "index", "fields": [...]}]}` | | `POST /monitors/{id}/heartbeat-token` | write | New secret URL for a heartbeat monitor: `{"heartbeat_url": "https://.../hb/"}` (the old one stops working) | | `GET /monitors/{id}/checks` | read | Recent checks, newest first. `before` (ms), `limit`, `outcome` | | `GET /checks` | read | Every monitor's checks in one list, newest first by time then monitor (the Activity page), each with its `monitor_id`, results not written yet included. `before` (the `next_cursor` of the page before, `"1791060910781:42"`), `limit` (1 to 500, default 100), `outcome` (across all monitors only `down`; any outcome with a `monitor_id`), `monitor_id` | | `GET /monitors/{id}/series` | read | Chart data. `range` = `1h` `24h` `7d` `30d` `90d` | | `GET /monitors/{id}/uptime` | read | Daily uptime, `days` up to 400 (default 90) | A check result: ```json {"at": "2026-10-04T01:15:00.000Z", "at_ms": 1791000000000, "outcome": "up", "latency_ms": 182, "phases": {"dns_ms": 4, "connect_ms": 61, "tls_ms": 70, "ttfb_ms": 45}, "status": 200, "error": null, "cert": {"not_after": "...", "issuer": "R11", "subject": "api.example.com"}, "detail": "final URL https://api.example.com/health"} ``` `series` answers `{"range": "24h", "resolution": "raw" | "hour" | "day", "points": [...]}`: - raw (1h, 24h): `{"t_ms", "outcome", "latency_ms", "dns_ms", "connect_ms", "tls_ms", "ttfb_ms", "status", "error_code"}` - hour (7d, 30d) and day (90d): `{"t_ms", "avg_ms", "p50_ms", "p95_ms", "min_ms", "max_ms", "up", "down", "slow", "unknown", "uptime"}` `uptime` answers `{"uptime": 99.9, "days": [{"date": "2026-10-04", "uptime": 100.0, "down_ms": 0, "checks": 2880}]}` (days in the configured timezone, oldest first, `uptime` null without data). ## Overview `GET /summary` (read): `{"counts": {"total", "up", "down", "slow", "paused", "unknown"}, "open_incidents": 1, "blind": {"active": false, "since": null}, "checks_per_min": 64.0, "groups": ["Chicago", ...], "tags": [...]}`. ## Incidents An incident opens when a monitor goes Down and closes when it comes back. `GET /incidents` (read): filters `state` (`open` `closed` `all`, default all), `monitor_id`, `before`, `limit`. Items: ```json {"id": 12, "monitor_id": 3, "monitor_name": "Pesa API", "started_at": "...", "ended_at": null, "duration_ms": 252000, "cause": {"code": "timeout", "message": "no answer in 10 s"}, "last_error": {...}, "fails": 4, "acknowledged_at": null, "note": null, "end_reason": null} ``` `end_reason`: `recovered`, `paused`, `deleted`, `config_changed`. `GET /incidents/{id}` (read) adds `checks` (its failed checks, the latest 200, newest first) and `timeline`, what happened in plain sentences and time order: `[{"at", "kind", "text"}]`, kinds `failing` (the first failed check), `down` (confirmed), `path` (the second opinion found a network path problem), `flapping`, `alert` (one line per message to a channel that the incident made, however late its retries: sent, failed after N tries, still retrying, or not sent because the monitor was muted), `ack`, `up` or `closed`. `PATCH /incidents/{id}` (write): `{"acknowledged": true, "note": "Mumbai box rebooted"}`. ## Alert channels ```json {"id": 1, "kind": "email", "name": "Owner email", "enabled": true, "events": ["down", "up", "slow", "flapping", "cert", "reminder", "notice"], "config": {"to": ["owner@example.com"]}, "status": {"state": "ok", "at": "...", "error": null}} ``` | Kind | `config` | |---|---| | `email` | `{"to": ["a@x.com", ...]}` (up to 5). Sent through the server's mail providers (Brevo first, Resend if Brevo fails) | | `discord` | `{"url": "https://discord.com/api/webhooks/..."}` (secret: masked in answers) | | `webhook` | `{"url": "https://...", "secret": "", "headers": [{"name", "value"}]}` (url, secret and header values masked) | Events: `down`, `up`, `slow` (and back to normal), `flapping`, `cert` (expiry warnings), `reminder`, `notice` (Farsight's own notices: offline periods, email paused, new sign-ins are always emailed to the owner separately). | Method and path | Scope | What it does | |---|---|---| | `GET /channels` | read | | | `POST /channels` | admin (+ step-up) | Create | | `PATCH /channels/{id}` | write for `name`/`enabled`/`events`; admin for `config` (+ step-up) | | | `DELETE /channels/{id}` | admin (+ step-up) | | | `POST /channels/{id}/test` | write | Sends a test message now: `{"ok": true, "provider": "brevo", "status": 201, "error": null}` | | `GET /deliveries` | read | Delivery log, one row per attempt: `{"id", "at", "channel_id", "channel_name", "ok", "provider", "status", "error", "summary", "outbox_id", "outbox_state", "monitor_ids"}` (`outbox_id` ties the attempts of one message together and `outbox_state` says where that message ended up: `sent`, `failed` (given up, or its channel was deleted) or `retrying`, null once the message itself has been pruned; `monitor_ids` lists the monitors it was about, null on rows from before 0.2.0 and on tests and sign-in emails); filter `channel_id` | Webhook body: `{"farsight": "1", "events": [{"type": "down", "at", "monitor": {"id", "name", "slug", "group", "target", "url"}, "message", "duration_ms"?, "error"?}]}`; headers `X-Farsight-Timestamp` (epoch seconds) and `X-Farsight-Signature` (hex HMAC-SHA256 of `.` under the channel's secret). Events that arrive within 20 seconds go out as one message per channel. ## Status page `GET /status-page` (read), `PUT /status-page` (write): ```json {"enabled": true, "title": "imemyself.dev status", "app_name": "09 status", "description": "Live status of ...", "hosts": ["status.example.com"], "group_order": ["Websites", "APIs", "Servers"], "monitors": [{"id": 3, "public": true, "public_name": "Pesa API", "public_group": "APIs", "sort": 0}]} ``` A GET also answers `domains` (one per `hosts` entry: `{"host", "state", "detail", "checked_at", "points_at": ["35.212.146.198"], "zone": "imemyself.dev", "provider": "cloudflare" | null, "records": [{"type": "A", "name": "statuss", "value": "35.212.146.198"}], "dashboard": "https://dash.cloudflare.com/?to=/:account/imemyself.dev/dns/records"}`, `records` and `dashboard` only while the DNS is not right) and `server_ips`. `state` is `checking`, `dns_missing`, `dns_elsewhere`, `proxied` (through Cloudflare's proxy), `certificate` (being fetched), `live`, `failed` or `not_managed` (this server does not serve HTTPS itself). Farsight reads each domain at its zone's own nameservers and fetches its certificate by itself once it points here; a save checks at once. `hosts` are stored as browsers ask for them (lowercase, punycode, no final dot), 5 at most, never an address; changing them needs the admin scope, and a fresh sign-in for a session. After three failed orders in a row a domain waits (1 hour, then 2, 4 ... up to a day, remembered across restarts and removals); a domain already live keeps serving its certificate meanwhile. `monitors` in a PUT updates each listed monitor's public settings in one go. `app_name` (30 characters at most; empty means the title) is the name under the icon when a visitor adds the page to a phone's home screen. A GET also answers `url` (the page's address) and `logo` (`{"url": "/status/logo?v=1a2b3c4d", "type": "image/png", "icon": "/status/icon.png?v=5e6f7a8b"}`, `icon` being `null` until one is made from the logo; or `logo` is `null`). How the page looks is part of the same object (every key optional in a PUT; checked before anything is saved): | Key | Values | Default | |---|---|---| | `layout` | `horizon`, `classic`, `ledger` | `horizon` | | `theme` | `auto` (follows the visitor), `light`, `dark` | `auto` | | `accent` | a colour like `#564be6` | `#564be6` | | `palette` | `standard` (green, amber, red), `colorblind` (blue, amber, orange) | `standard` | | `font` | `geist`, `plex`, `system` | `geist` | | `bar_style` | `worst` (a bar takes the colour of the worst moment of its period), `share` (a bar shows how much of its period was down) | `worst` | | `bar_shape` | `rounded`, `square`, `slim` | `rounded` | | `ranges` | any of `24h`, `7d`, `30d`, `90d`, `12mo` (at least one) | all five | | `default_range` | one of `ranges` | `90d` | | `show_response`, `show_uptime`, `show_summary` | response times, uptime figures, the summary numbers | `true` | | `fold_groups` | fold sections whose services are all fine | `true` | | `auto_refresh` | refresh the page every minute (in place; a browser without scripts reloads it) | `false` | | `website` | `{"label", "url"}` (http or https) or `null`: the logo links there, the footer names it | `null` | | `links` | up to 3 `{"label", "url"}` (http, https or mailto) in the header | `[]` | The page itself is `GET /status` (HTML, public, cached 30 s; `?range=24h|7d|30d|90d|12mo`, anything else gives the default) and `GET /api/public/status` (JSON, public, same `range`): ```json {"title", "description", "overall": "operational" | "degraded" | "partial_outage" | "major_outage", "updated_at", "range": "90d", "ranges": ["24h", "7d", "30d", "90d", "12mo"], "summary": {"overall", "uptime": 99.98, "incidents": 2, "longest_ms": 711000, "typical_ms": 212, "services": 19}, "groups": [{"name": "Apps", "status": "up", "uptime": 99.99, "bars": [...], "services": [{"name": "Pesa", "page": "/status/pesa", "status": "up", "uptime": 99.98, "down_ms": 0, "typical_ms": 65, "from": "90 days ago", "bars": [{"period": "4 Oct", "label": "4 Oct: 100% up", "state": "ok", "uptime": 100.0, "down_ms": 0, "avg_ms": 64}]}]}], "incidents": {"open": [{"name", "page", "started_at", "ended_at": null, "duration_ms"}], "recent": [...]}} ``` More public pages, in the same look: `GET /status/` (one service: its bars, a response-time chart, the last 12 months, its incidents; `` is the `page` in the JSON, from the public name), `GET /status/history` (every incident of a public service, three local months a page, newest first; `?before=YYYY-MM` shows the three months before that month). The look's files: `/status.css?v=` (a year), `/status/theme.css?a=564be6` (the accent), `/status/fonts/`, `/status/logo`. The pages carry one script of their own, `/status.js?v=` (a year; the CSP allows nothing else, and nothing inline). Without it everything works as plain links in the page's time zone. With it: range tabs switch in place, at once (the other ranges are fetched while the page sits idle, and a tab about to be chosen is fetched on hover or touch); times show in the visitor's own time zone (every moment is a `