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

Monitors

A monitor is a kind plus that kind's settings under a key of the same name, plus common settings:

{
  "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

FieldDefaultRulesMeaning
namerequired1-100 charsWhat you call it
slugfrom namea-z0-9-, up to 60, uniqueStable handle for agents and imports
kindrequiredhttp tcp ping dns tls heartbeatWhat kind of check
groupnullup to 60Groups the dashboard list
tags[]up to 20, each 1-30Free labels
descriptionnullup to 500Shown in alerts
runbook_urlnullhttp(s) URLWhere the fix is written down; linked in alerts
interval_s6010-86400 (heartbeat 30-2592000)Time between checks
timeout_ms10000500-60000, not above the intervalWhole-check deadline
confirm_after21-10Failed checks in a row before Down
retry_interval_s105 to the intervalRe-check delay after a failure
recover_after11-10Good checks in a row before Up
slow_msnull1-60000, 0 or null = offA good check slower than this is Slow
slow_after31-20Slow checks in a row before the monitor shows Slow
alert_on_slowfalseSend alerts for Slow
channelsnullchannel ids, or nullWho hears about it; null = every enabled channel, [] = nobody
remind_every_min00 or 5-10080While Down, remind this often (0 = never)
pausedfalseNo checks, no alerts
muted_untilnullRFC 3339 or nullChecks run, alerts are held back until then
publicfalseShown on the status page
public_namenullup to 100Name on the status page (defaults to name)
public_groupnullup to 60Section on the status page (defaults to group)
sort0integerOrder within a group (low first)

Read-only fields in answers: id, target (a one-line description), created_at, updated_at, state, stats.

Kinds

**http**

FieldDefaultMeaning
urlrequiredhttp:// or https://. Credentials in the URL are moved into auth
methodGETGET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS
headers[][{"name", "value", "secret": false}]; a secret header's value is masked in answers
body, content_typenullRequest body (up to 64 KB) and its type
authnull{"type": "basic", "username", "password"} or {"type": "bearer", "token"} (secrets masked in answers)
follow_redirectstrueUp to 5 hops
ip_versionautoauto, ipv4, ipv6
resolve_tonullConnect to this IP but keep the URL's host (check an origin behind a CDN)
tls_verifytruefalse accepts self-signed or invalid certificates
tls_warn_days14Warn at 14, 7, 3 and 1 days before expiry (0 = off)
expect_status200-399Codes and ranges, e.g. 200-299,401
body_contains, body_not_containsnullText 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_headernull{"name": "content-type", "contains": "json"}
max_msnullSlower 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

"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 pathScopeWhat it does
GET /monitorsreadAll monitors with state and stats. Filters: status, group, tag, kind, q (name, slug, target)
GET /monitors/{id}readOne monitor ({id} may also be slug:<slug>)
POST /monitorswriteCreate. 201 with the monitor. A heartbeat monitor's answer includes heartbeat_url once
PATCH /monitors/{id}writeChange only the fields sent (kind settings merge field by field; null clears)
PUT /monitors/by-slug/{slug}writeCreate or replace by slug (idempotent; what agents should use)
DELETE /monitors/{id}write (+ step-up)Delete with its history
POST /monitors/{id}/pause, /resumewriteStop or restart checks
POST /monitors/{id}/mutewriteBody {"until": "<RFC 3339>"} or {"minutes": 60}
POST /monitors/{id}/unmutewrite
POST /monitors/{id}/checkwriteCheck now; answers with the result (also fed to the state machine)
POST /monitors/testwriteBody: a monitor (unsaved). Runs one check and returns the result; changes nothing
POST /monitors/bulkwrite{"monitors": [...], "dry_run": false}; creates or updates each by slug. Answers {"created": [slugs], "updated": [...], "unchanged": [...], "errors": [{"slug", "index", "fields": [...]}]}
POST /monitors/{id}/heartbeat-tokenwriteNew secret URL for a heartbeat monitor: {"heartbeat_url": "https://.../hb/<token>"} (the old one stops working)
GET /monitors/{id}/checksreadRecent checks, newest first. before (ms), limit, outcome
GET /checksreadEvery 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}/seriesreadChart data. range = 1h 24h 7d 30d 90d
GET /monitors/{id}/uptimereadDaily uptime, days up to 400 (default 90)

A check result:

{"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": [...]}:

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:

{"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

{"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}}
Kindconfig
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": "<signing 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 pathScopeWhat it does
GET /channelsread
POST /channelsadmin (+ 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}/testwriteSends a test message now: {"ok": true, "provider": "brevo", "status": 201, "error": null}
GET /deliveriesreadDelivery 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 <timestamp>.<raw body> 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):

{"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):

KeyValuesDefault
layouthorizon, classic, ledgerhorizon
themeauto (follows the visitor), light, darkauto
accenta colour like #564be6#564be6
palettestandard (green, amber, red), colorblind (blue, amber, orange)standard
fontgeist, plex, systemgeist
bar_styleworst (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_shaperounded, square, slimrounded
rangesany of 24h, 7d, 30d, 90d, 12mo (at least one)all five
default_rangeone of ranges90d
show_response, show_uptime, show_summaryresponse times, uptime figures, the summary numberstrue
fold_groupsfold sections whose services are all finetrue
auto_refreshrefresh 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 itnull
linksup 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):

{"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/<name> (one service: its bars, a response-time chart, the last 12 months, its incidents; <name> 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/<file>, /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 <time datetime>; hours and 6-hour blocks are relabelled, while day and month bars stay the page's days, and the footer says so); the page refreshes in place every minute when auto_refresh is on, and whenever it comes back to the front after a minute away (a failed refresh keeps the page and says "offline"; an update time from another day names the day; a change of look or of the script reloads the page whole).

Installing the page as an app: /status/manifest.webmanifest (public; name is the title, short_name the app_name; it starts at /status, or at / on one of the page's hosts) and the home-screen icon, which is Farsight's (/icon-512.png, /apple-touch-icon.png) unless an icon was made from the logo (/status/icon.png?v=). The dashboard makes that icon when a logo is uploaded: PUT /status-page/icon?logo=<v> (write) with a square PNG of 180 to 1024 pixels as the raw body (Content-Type: image/png, 256 KB at most) answers {"ok": true, "v": "5e6f7a8b", "url": "/status/icon.png?v=5e6f7a8b"}; logo (optional) is the version of the logo it was drawn from, and the answer is 409 when the logo has changed since, or when there is no logo. The icon belongs to the logo: uploading a new logo or removing it drops the icon, and the page goes back to Farsight's.

A preview of changes before saving them: POST /status-page/preview (write) takes {"settings": {...}, "monitors": [...], "range": "7d"} (every part optional; settings and monitors as in a PUT, checked the same way; hosts is ignored) and answers {"html": "<!doctype html>...", "range": "7d"}: the real page, drawn from the saved settings with the draft on top, marked as a preview (links inert, never refreshing, no script, nothing to install). Nothing is stored. The dashboard shows it in a sandboxed frame with no scripts.

The logo: PUT /status-page/logo (write) with the image as the raw body and its Content-Type (image/png, image/jpeg, image/webp or image/svg+xml, 256 KB at most; the bytes must match the type) answers {"ok": true, "v": "1a2b3c4d", "url": "/status/logo"}; DELETE /status-page/logo (write) removes it. An SVG logo is served with a sandboxing CSP, so nothing in it can run.

Bars: 24 hourly bars for 24h, 28 six-hour blocks for 7d, days for 30d and 90d, months for 12mo. state is not_watched (before the service was watched), no_data, ok, warn (any downtime, or under 99.9%; for 12mo months only under 99.9%, so one short blip leaves a month green), bad (under 99%). No monitor ids, hosts, addresses or errors are ever in it.

Settings and data

GET /settings (read), PATCH /settings (admin + step-up):

{"defaults": {"interval_s": 60, "timeout_ms": 10000, "confirm_after": 2, "retry_interval_s": 10, "recover_after": 1, "slow_after": 3},
 "tz_offset_min": 330,
 "email": {"configured": true, "providers": ["brevo", "resend"], "from": "Farsight <alerts@imemyself.dev>", "daily_cap": 80, "sent_today": 3},
 "owner_email": "owner@example.com", "public_url": "https://farsight.example.com",
 "retention": {"checks_days": 7, "hourly_days": 400, "events_days": 180}}

GET /export (admin + step-up): {"farsight_export": 1, "exported_at", "monitors": [...], "channels": [...], "status_page": {...}} (secrets masked unless ?secrets=1). POST /import (admin + step-up): an export, plus "dry_run": true|false. Monitors are matched by slug, channels by name. Answers like /monitors/bulk, plus the same for channels.

API keys

GET /keys (admin): {"id", "name", "prefix": "fs_3f2a", "scope", "created_at", "expires_at", "last_used_at", "last_ip"}. POST /keys (admin + step-up): {"name": "claude agent", "scope": "write", "expires_at": null}; the answer has "key": "fs_..." once. DELETE /keys/{id} (admin + step-up).

System

Sign-in (dashboard)

All sign-in routes need a Turnstile token ("turnstile": "<token>") when the server has Turnstile configured. Password and code attempts are limited per IP (an IPv6 client per /64): 5 failures in 15 minutes block that address for an hour; 3 blocks in a day block it for a day. A blocked address gets the same answer as a wrong password. Passkey sign-in is never blocked by address.

Method and pathBodyAnswer
GET /auth/state{"setup_needed", "signed_in", "methods": {"passkey": bool, "password": bool}, "turnstile_site_key": "..." or null}
POST /auth/passkey/options{"turnstile"}{"challenge_id", "public_key": {"challenge", "rpId", "timeout", "userVerification": "required", "allowCredentials": []}} (base64url)
POST /auth/passkey/verify{"challenge_id", "credential": {"id", "rawId", "type", "response": {"clientDataJSON", "authenticatorData", "signature", "userHandle"}}}{"ok": true} and the session cookie
POST /auth/password{"email", "password", "turnstile"}{"ticket", "next": "code"} (the ticket lasts 5 minutes)
POST /auth/code{"ticket", "code"} or {"ticket", "recovery_code"}{"ok": true} and the cookie
POST /auth/forgot{"email", "turnstile"}always {"ok": true}; the owner gets a link if the email matches
POST /auth/reset{"token", "password", "code" or "recovery_code", "turnstile"}{"ok": true} and the cookie
POST /auth/logout{"ok": true}
GET /auth/setup/{token}{"valid", "email", "expires_at", "totp": {"secret", "uri", "qr_svg"}}
POST /auth/setup/{token}{"email", "password", "code", "turnstile"}{"ok": true, "recovery_codes": [10 codes]} and the cookie
GET /auth/enroll/{token}{"valid", "expires_at"} (break-glass link from farsightd enroll-link)
POST /auth/enroll/{token}/options{"turnstile"}{"challenge_id", "public_key": {...creation options...}}
POST /auth/enroll/{token}/verify{"challenge_id", "credential", "label"}{"ok": true} and the cookie
POST /auth/step-up/passkey/options{}request options
POST /auth/step-up/passkey/verify{"challenge_id", "credential"}{"fresh_until"}
POST /auth/step-up/password{"password", "code"}{"fresh_until"}

The signed-in owner

Method and pathWhat
GET /me{"email", "created_at", "session": {"method", "created_at", "fresh_until"}, "passkeys": 2, "totp": true, "recovery_codes_left": 9}
GET /me/sessions{"items": [{"id", "current", "method", "created_at", "seen_at", "expires_at", "ip", "device"}]}
DELETE /me/sessions/{id}, POST /me/sessions/revoke-othersstep-up
GET /me/passkeys{"items": [{"id", "label", "created_at", "last_used_at"}]}
POST /me/passkeys/optionsstep-up; creation options {"challenge_id", "public_key"}
POST /me/passkeys/verify{"challenge_id", "credential": {"id", "rawId", "type", "response": {"clientDataJSON", "attestationObject", "transports"}}, "label"}
PATCH /me/passkeys/{id}{"label"}
DELETE /me/passkeys/{id}step-up
POST /me/passwordstep-up; {"current", "new"}; signs out every other session: {"ok": true, "signed_out": n}
POST /me/totp/optionsstep-up; {"setup_id", "secret", "uri", "qr_svg"}
POST /me/totpstep-up; {"setup_id", "code"}; replaces the authenticator and signs out every other session: {"ok": true, "signed_out": n}
POST /me/recovery-codesstep-up; {"codes": [10 new codes]} (old ones stop working)
GET /me/blocked-ips, DELETE /me/blocked-ips/{ip}blocked addresses and unblocking (delete needs step-up)

Public routes (no sign-in)