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://<your farsight host>/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 (orPOST /keys). Scopes:read<write<admin. - The dashboard: the
farsight_sessioncookie (HttpOnly, Secure, SameSite=Strict). Cookie requests that change something must sendContent-Type: application/jsonand anOriginheader equal to the dashboard's origin. - Scopes:
readsees everything (secrets are always masked).writechanges monitors, incidents, the status page and channel settings without secrets.adminalso 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_msor_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_request400,sign_in401,forbidden403,step_up403,not_found404,conflict409,invalid422 (withfield/fields),too_many_requests429 (withRetry-After),internal500,unavailable` 503. - Lists are
{"items": [...], "next_cursor": <value or null>}; pass?before=<next_cursor>for the next page and?limit=(default 50, max 500). - Idempotency: send
Idempotency-Key: <any unique string>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:
{
"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
"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:<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": "<RFC 3339>"} 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/<token>"} (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:
{"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:
{"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}}
| 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": "<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 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 <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):
| 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):
{"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
GET /diagnostics(read): version, uptime, memory, monitors, checks per minute, database and WAL size, writer state, canary, outbox, email counts, an egress estimate, recent problems from the log.GET /events(read): the timeline (state changes, incidents, certificate warnings, offline periods, config changes with who made them, sign-ins). Filterskind(one kind, or several comma-separated:down,up,path),monitor_id,before,limit.GET /stream(read): Server-Sent Events. Events:hello{"version"},check{"monitor_id", "at_ms", "outcome", "latency_ms", "status", "error"},state{"monitor_id", "state": {...}},incident{incident},blind{"active", "since"},path{"host", "at"}(a network path problem),monitors{"changed": [ids], "deleted": [ids]}. A comment line every 25 s keeps it open. Cookie or API key (Authorizationheader).
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 path | Body | Answer |
|---|---|---|
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 path | What |
|---|---|
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-others | step-up |
GET /me/passkeys | {"items": [{"id", "label", "created_at", "last_used_at"}]} |
POST /me/passkeys/options | step-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/password | step-up; {"current", "new"}; signs out every other session: {"ok": true, "signed_out": n} |
POST /me/totp/options | step-up; {"setup_id", "secret", "uri", "qr_svg"} |
POST /me/totp | step-up; {"setup_id", "code"}; replaces the authenticator and signs out every other session: {"ok": true, "signed_out": n} |
POST /me/recovery-codes | step-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)
GET /api/health:{"ok": true, "version"}.GET /status,GET /api/public/status: the status page.GET|POST|HEAD /hb/{token}: a heartbeat.POST /hb/{token}/failreports a failure (body: up to 1 KB of text, shown in the alert).GET /llms.txt,GET /docs/...,GET /api/openapi.json.