# HTTP API

The JSON API, the endpoints for agents, browsers, and CI, and authentication.

The panel uses the JSON API at `/api/v1`. Response shapes may change between minor
versions. There is no OpenAPI document.

## Paths

| Method and path | What it is | Auth |
|---|---|---|
| `GET /` and other panel paths | the panel | none to load the panel |
| `GET /assets/*` | static files of the panel, cached for a year | none |
| `/api/v1/...` | the JSON API, see below | session or token |
| `POST /api/deploys` | mark a deploy, see [Deploy API](https://docs.kogut.dev/deploys/api/) | token (`deploy`) |
| `POST /api/changes` | mark a change such as a migration, see [Change API](https://docs.kogut.dev/deploys/changes/) | token (`deploy`) |
| `POST /hooks/github` | the GitHub webhook, see [GitHub](https://docs.kogut.dev/deploys/github/) | HMAC signature |
| `GET`, `POST /ping/<token>` | heartbeats, plus `/start`, `/fail`, and `/<exit code 0-255>` | the ping token in the URL |
| `GET`, `POST /api/push/<token>` | heartbeats as push URLs, with `?status=up` or another status for a failure; answers JSON `{"ok":true}`, see [Keep an existing push URL](https://docs.kogut.dev/monitoring/heartbeats/#keep-an-existing-push-url) | the ping token in the URL |
| `GET /b.js` (also `/script.js`), `POST /e` | the tracking script and its events | none, the site must be known |
| `POST /agent/v1/frames`, `GET /agent/v1/poll` | agents | host token |
| `POST /mcp` | the [MCP server](https://docs.kogut.dev/reference/mcp/) | token (`mcp`) |
| `GET /healthz` | liveness: `ok` and 200 when healthy, the reason and 503 when not | none |
| `GET /metrics` | Prometheus metrics about kogut itself | session or token (`read`) |
| `GET /status` | the [status page](https://docs.kogut.dev/status-page/) on the panel's own address | none |
| `POST /status/subscribe` | subscribes the form's `email` to [email updates](https://docs.kogut.dev/status-page/#enable-email-updates) and sends a confirmation link on both hosts | none |
| `GET`/`POST /status/confirm?token=...` | a page with a button, and the confirmation it posts | the token |
| `GET`/`POST /status/unsubscribe?token=...` | a page with a button, and the unsubscribe it posts, which also serves as the one-click unsubscribe of mail apps | the token |
| any other path on the status domain | the status page, only `GET` and `HEAD` | none |

Unknown paths under `/api`, `/agent`, `/auth`, `/e`, `/ping`, `/hooks`, and `/mcp` answer
404. Other unknown paths load the panel.

### `/api/v1`

Reads take `range=1h`, `24h`, `7d`, `30d`, or `1y`, or `from` and `to` in milliseconds
(up to 400 days), but not both.

| Method and path | What it does | Who |
|---|---|---|
| `GET /api/v1/auth` | `{needsSetup, user, password, providers, proxy}`: whether the first account is missing, the signed-in user, if any, whether passwords are on, `{name, url}` of each OpenID Connect provider, and whether a proxy signs people in | anyone |
| `POST /api/v1/auth/setup` | create the first account with the setup token: `{token, email, password}` | anyone with the setup token |
| `POST /api/v1/auth/login` | sign in: `{email, password}`. Sets the session cookie | anyone |
| `POST /api/v1/auth/logout` | sign out | session |
| `GET /api/v1/auth/me` | the signed-in user: `{email, role}` | session |
| `GET /api/v1/config/status` | the current `kogut.toml` error, or `null` | read |
| `GET /api/v1/config/export` | `{toml, secrets}`: what was created in the panel as `kogut.toml`, the text [`kogut export`](https://docs.kogut.dev/reference/cli/#kogut-export) prints, and the `{name, key, about}` of each `${NAME}` it needs, without values | write |
| `GET /api/v1/overview` | the overview screen, default range 24h | read |
| `GET /api/v1/warnings` | what could keep alerts from arriving: a host that shares kogut's machine while neither kogut edge nor a heartbeat URL is set (`sharedHost`), the number of notification channels (`channels`), and a private proxy that is not trusted while nearly all recent page views come through it (`untrustedProxy`: `address` and `sharePercent`, or null) | read |
| `GET /api/v1/services`, `/services/{name}` | services, and one service | read |
| `GET /api/v1/incidents`, `/incidents/{id}` | open incidents and those resolved in the range (default 30d, at most 200), and one incident | read |
| `POST /api/v1/incidents/{id}/ack` | acknowledge an incident, which stops reminders | write |
| `GET /api/v1/hosts`, `/hosts/{name}` | hosts, and one host. A host's `checkConflicts` lists the command checks in its agent's file that were not added: `{name, with, host}`, where `with` is `file`, `panel`, or `host` for the check that has the name, or `free` when the check is added at the agent's next declaration, within an hour | read |
| `GET /api/v1/hosts/{name}/containers/{container}`, `/hosts/{name}/units/{unit}` | CPU (percent of one CPU) and memory (bytes) of one container or systemd unit over the range, default 24h: `{x, stepMs, cpu, cpuMax, mem, memMax}`, with `null` where it did not run | read |
| `POST /api/v1/hosts` | add a host, or replace its token: `{name}`. Returns the host token `bth_...` and the install command once | write |
| `PUT /api/v1/hosts/{name}/alerts` | set the alert thresholds of a host added in the panel: `{cpu, memory, swap, disk, temperature, ignoreMounts}`, with `null` for an alert that is off. A host in `kogut.toml` answers 409. `GET /api/v1/hosts/{name}` returns them as `alerts`, with `fromFile` | write |
| `POST /api/v1/discovered/dismiss` | hide discovered items: `{ids: [...]}` | write |
| `POST /api/v1/discovered/monitor` | monitor something an agent found: its service, the host it runs on, and its check, see [below](#checks) | write |
| `GET /api/v1/analytics` | Site analytics for `?site=<domain>`, default first site. Uses daily rollups beyond raw retention and omits metrics without full-range coverage. `previous` is null without full previous-period coverage. `series` has bucketed metrics; `bounce` (percent) and `time` (milliseconds) cover visits started in each bucket, or null without visits. `dimensions` has the ten top rows of each list and its `total`. `sharedAddress` flags nearly all recent page views sharing one address: `forwarded` is true when they named a visitor in `X-Forwarded-For` or `X-Real-IP` from an untrusted peer, and `proxy` is that peer's address when it is private (RFC 1918, `fc00::/7`, link-local or `100.64.0.0/10`), otherwise null. | read |
| `GET /api/v1/analytics/{list}` | Site dimension: `pages`, `referrers`, `countries`, `devices`, `browsers`, `operatingSystems`, `utmSources`, `utmMediums`, or `utmCampaigns`. `search` is a case-insensitive substring. `limit` is 1 to 1000, default 100. `total` counts all matches. Pages rank by views, others by visitors. Campaign lists exclude views without that parameter. | read |
| `POST /api/v1/sites` | Add `{domain, service, check}` atomically and return the tracking snippet. Optional `service` is created if missing; optional `check` is a [check body](#checks) for that service. | write |
| `GET /api/v1/settings` | the settings screen, with channels, silences, and maintenance | read |
| `POST /api/v1/checks` | add a check or heartbeat, see [below](#checks) | write |
| `GET /api/v1/drafts` | checks proposed over MCP that wait for approval, see [below](#drafts) | read |
| `POST /api/v1/drafts/{id}/approve`, `POST /api/v1/drafts/{id}/reject` | approve a draft, which creates its check, or reject it | admin session only |
| `GET /api/v1/checks/{id}` | a check's definition: `{id, managed, input}` | read |
| `GET /api/v1/checks/{id}/detail` | a check's page: its newest runs, uptime, response times, and expiry date, see [below](#a-checks-runs) | read |
| `GET /api/v1/checks/{id}/history` | a page of a check's failed runs and state changes, newest first, see [below](#a-checks-runs) | read |
| `PUT /api/v1/checks/{id}`, `DELETE /api/v1/checks/{id}` | change or delete a check added in the panel | write |
| `POST /api/v1/checks/{id}/ping-url` | create a heartbeat's ping URL | write |
| `POST /api/v1/channels` | add a notification channel, see [below](#channels) | write |
| `PUT /api/v1/channels/{name}`, `DELETE /api/v1/channels/{name}` | change or delete a channel added in the panel | write |
| `POST /api/v1/channels/{name}/test` | send a test message to a channel, such as `ntfy` or `ops-phone` | write |
| `GET /api/v1/watchdog` | kogut edge and the heartbeat URL: where they point, whether they come from `kogut.toml`, and the last call or why it failed, see [below](#kogut-watchdogs) | read |
| `PUT /api/v1/watchdog/edge`, `DELETE /api/v1/watchdog/edge` | set or remove kogut edge in the panel | write |
| `PUT /api/v1/watchdog/heartbeat-url`, `DELETE /api/v1/watchdog/heartbeat-url` | set or remove the heartbeat URL in the panel | write |
| `POST /api/v1/silences`, `POST /api/v1/maintenance` | add a silence or a maintenance window, see [below](#silences-and-maintenance) | write |
| `PUT`, `DELETE /api/v1/silences/{id}` and `/api/v1/maintenance/{id}` | change or remove one | write |
| `POST /api/v1/users` | add a person: `{email, role, password}` | admin session only |
| `POST /api/v1/tokens` | create an API token: `{scope, label}` | admin session only |
| `DELETE /api/v1/tokens/{id}` | revoke a token | admin session only |
| `GET /api/v1/status-preview` | the status page as JSON | read |
| `GET /api/v1/stream` | server-sent events for live updates | read |

`read` means a session of any role or a `read` or `write` token. `write` means an
admin's session or a `write` token. A viewer's session gets 403 "only admins can do
this".

## Authentication

The panel uses `kogut_session` after [password](https://docs.kogut.dev/sign-in/passwords/) or
[OpenID Connect](https://docs.kogut.dev/sign-in/openid-connect/) sign-in.
[Forward auth](https://docs.kogut.dev/sign-in/forward-auth/) uses the proxy's identity headers instead.
Sessions last 30 days. The cookie is `HttpOnly`, `SameSite=Lax`, and `Secure` with an
HTTPS public URL.

API tokens start with `kg_` and go only in the `Authorization: Bearer <token>` header,
not in query parameters. A token takes precedence over a cookie. Missing credentials
return 401, the wrong scope 403. [`kogut status`](https://docs.kogut.dev/reference/cli/#kogut-status),
`kogut incidents`, and `kogut checks` take a `read` token.

| Scope | Allows |
|---|---|
| `read` | Monitoring and settings reads under `/api/v1`, the event stream, and `/metrics`. Not session-only auth routes. |
| `write` | everything `read` allows, plus acknowledging incidents, adding hosts and sites, host alert thresholds, monitoring and dismissing discovered items, checks, channels, silences, maintenance, watchdog settings, channel tests, and ping URLs. Not users or tokens. |
| `deploy` | only `POST /api/deploys` and `POST /api/changes` |
| `mcp` | only `POST /mcp`: reads, and [drafts](https://docs.kogut.dev/reference/mcp/#drafts) of checks that only an admin's session approves |

The panel also offers `ping` and `agent` scopes, which no route accepts yet. Heartbeats
use their [ping URL](https://docs.kogut.dev/monitoring/heartbeats/), and agents the host token from
**Hosts → Add a server**.

Tokens are shown once, at creation. kogut stores their SHA-256 hashes and last-use
times. Writes authenticated by a cookie or forward auth need an `Origin` equal to
`[server] url`, or to the request's `Host` when no URL is set. Bearer tokens do not.

### Ping URLs

`POST /api/v1/checks/{id}/ping-url` returns `201` with
`{"url": "https://kogut.acme.com/ping/btp_...", "replaced": false}`. The heartbeat's
public id is in `GET /api/v1/services/{name}`. Another call invalidates the old URL and
returns `replaced: true`. A `ping_token` from `kogut.toml` is not replaced and keeps
working; `replaced` counts only URLs made here.

### Checks

`POST /api/v1/checks` validates checks and heartbeats by the
[configuration rules](https://docs.kogut.dev/reference/configuration/), with durations in milliseconds. Nullable fields
may be omitted; `null` uses the default:

```json
{
  "name": "API health", "service": "api", "kind": "http",
  "target": "https://api.acme.com/health", "method": null,
  "everyMs": 30000, "timeoutMs": null, "from": null,
  "expect": [
    { "status": "2xx", "bodyContains": null, "bodyNotContains": null, "json": null,
      "equals": null, "responseTimeBelowMs": null, "degraded": false }
  ],
  "cron": null, "timezone": null, "graceMs": null
}
```

`kind` is `http`, `tcp`, `dns`, `ping`, `domain`, or `heartbeat`. `command` exists only
on [command checks](https://docs.kogut.dev/agent/command-checks/) from an agent's host and is refused here
with `422`. `target` is the URL, `host:port`, DNS name, ping host, or registered domain.
Ping assertions take only `responseTimeBelowMs`. Domain checks take neither `everyMs` nor
`from`. Heartbeats take `everyMs` or `cron`, optional `timezone`, and `graceMs`, and no
target. `from` names an agent host. Missing services are created.

Success returns `201` with `{"id": "<public id>"}` and schedules the check at once. A
name conflict returns `409`, invalid values `422` with the field in the detail.
`PUT /api/v1/checks/{id}` takes the same body. `DELETE` removes the check and its history.
File-managed and command checks refuse both with `409`. `GET` returns
`{id, managed, hostDefined, input}`.

`POST /api/v1/discovered/monitor` takes `{host, service, check}`, where `check` is the
body above or `null`. It atomically creates a missing service, links the host, and adds
the check, and returns `201` with `{service, check}`. File-managed services return `409`.

### Drafts

`GET /api/v1/drafts` returns `{drafts}`, oldest first. Each draft has `id`, `check` (the
[check body](#checks) that approving creates), `proposedBy` (such as `token "assistant"`),
`createdAt`, `expiresAt`, and `problem`: why approving would fail now, or `null`.

`POST /api/v1/drafts/{id}/approve` creates the check as `POST /api/v1/checks` does and
returns `201` with `{"id": "<public id>"}`. A name conflict returns `409` and invalid
values `422`; the draft stays open. `POST /api/v1/drafts/{id}/reject` returns `204` and
creates nothing. Both return `404` for an unknown draft and `409` for one already decided
or expired. Tokens of every scope and viewers' sessions get `403`. Changes to drafts
invalidate `drafts` in the [event stream](#live-updates).

### A check's runs

`GET /api/v1/checks/{id}/detail` accepts a range, default `24h`, and returns:

| Field | Meaning |
|---|---|
| `check` | Service check row, including `id`, `name`, `kind`, `status`, and `expiry`. A command check has `hostDefined` set, with `from` naming its host, `lastDetail` its last message, and `lastValue` its last number. |
| `service` | Service name. |
| `beats` | Newest 90 runs, oldest first: `{ts, status, latencyMs, detail}`. |
| `uptime` | `{day, month, year}` percentages of up or degraded runs over 24 hours, 30 days, and 365 days. `null` without runs. Independent of the requested range. |
| `dayAverageMs` | Mean latency of up or degraded runs over the last 24 hours. |
| `series` | `{x, stepMs, p50, p95, value}` buckets for the requested range: response times, and the mean number a command check printed, `null` where no run printed one. `value` is empty when no run in the range printed a number. |

`expiry` is `{kind, expires, seen}` for the latest observed certificate or domain
registration, or `null` without one or after the target host, port, or domain changes.
Service check rows include it too.

`GET /api/v1/checks/{id}/history` accepts `before` in milliseconds and `limit` from 1 to
100, default 20. It returns `{items, next}`, newest first. Each item has
`{ts, kind, status, title, detail, latencyMs}`:

- `failure` is a down or degraded run, kept for `[retention] check_results`.
- `change` is a check or certificate state change, including before a service move.

`next` is the next page's `before`, or `null` at the end. Pages never split a timestamp,
so a page can have fewer items than `limit`, or more when the newest timestamp alone has
more. Both endpoints return `404` for unknown ids and use `service:<name>`
[live invalidations](#live-updates).

Incident timeline rows have the changed check's id in `check`, or `null`, and for a
deploy or change who made it in `by` and its link in `url`. Incident timelines also show
[changes](https://docs.kogut.dev/deploys/changes/) of no service or host. The overview lists every change
in the range in `changes`; a service or host lists its own.

### Channels

`POST /api/v1/channels` accepts these fields:

| Field | Meaning |
|---|---|
| `name` | Lowercase letters, digits, and hyphens. File-style names such as `ntfy` and `slack-2` return `422`. |
| `kind` | `ntfy`, `webhook`, `slack`, `discord`, `telegram`, or `email`. |
| `url` | ntfy topic or webhook, Slack, or Discord URL. |
| `secret` | ntfy token, webhook signing secret, Telegram bot token, or SMTP password. |
| `chatId` | Telegram chat id. |
| `smtp`, `tls`, `username`, `from`, `to` | Email settings, validated like [`[[alert]]`](https://docs.kogut.dev/reference/configuration/#alert). `to` is an address list. `tls: null` selects by port. Without `username`, no password is kept. Other kinds use `null`. |
| `allowPrivate` | Applies to ntfy, webhooks, and email. |

`PUT /api/v1/channels/{name}` takes the same body and name. `secret: null` keeps the
stored secret, and for Slack and Discord `url: null` keeps the stored URL. An empty string
removes an optional secret. File-managed channels refuse changes with `409`.

In `GET /api/v1/settings`, admins and `write` tokens get each panel channel's `settings`
without secrets and with `hasSecret`. Viewers and `read` tokens get `settings: null`,
since URLs may contain secrets.

### kogut watchdogs

| Endpoint | Body |
|---|---|
| `PUT /api/v1/watchdog/edge` | `{url, token}`. `token: null` keeps the stored token. |
| `PUT /api/v1/watchdog/heartbeat-url` | `{url}`. |

Both return `204` and send the first request at once. `PUT` and `DELETE` return `409`
while `kogut.toml` sets the same thing.

`GET /api/v1/watchdog` returns `{edge, heartbeat}`. Each is `null` or an object with
`{address, managed, lastSentAt, lastError, lastErrorAt}`, without tokens. A heartbeat
address shows only the host. `lastError` stays set until a request succeeds.

### Silences and maintenance

`POST /api/v1/silences` and `POST /api/v1/maintenance` take:

```json
{ "scope": "service", "name": "api", "starts": 1760533200000, "ends": 1760536800000,
  "reason": "database upgrade" }
```

`scope` is `all`, `service`, or `host`; `name` is `null` for `all`. A window must end
after it starts, end in the future, and last at most 90 days. `createdBy` records the
caller. `PUT .../{id}` changes scope, times, and reason, and `DELETE .../{id}` removes the
window. `GET /api/v1/settings` lists ids under `holds`.

## Conventions

Incidents and other objects use 16-character hexadecimal public ids, such as
`3f9c2e1a7b4d0c55`. Services and hosts use names. Timestamps are UTC milliseconds since
the Unix epoch.

Errors use `application/problem+json` with `type: "about:blank"`, `status`, `title`, and
an optional `detail`. `429` means a client rate limit, and `503` that the server is at
capacity, such as a full write queue. Both include `Retry-After` in seconds.

| Limit | Value |
|---|---|
| Tracker events per visitor address | bursts of 30, then 2 per second |
| Sign-in and setup attempts per address | bursts of 10, then 1 per second |
| Password checks at once | 2. More return 503. |
| Channel tests at once | 1. More return 429. |
| Live update streams | 64 |
| Body of `POST /e` | 8 KiB |
| Body of `POST /api/deploys`, `POST /api/changes`, and `POST /mcp` | 64 KiB |
| Body of `POST /hooks/github` | 5 MiB |
| Body of `POST /agent/v1/frames` | 4 MiB |

Addresses come from `X-Forwarded-For` only behind a proxy in `trusted_proxies` in
[kogut.toml](https://docs.kogut.dev/reference/configuration/).

## Live updates

`GET /api/v1/stream` sends invalidations as server-sent events, at most every 500 ms:

```text
data: {"invalidate":["incident:3f9c2e1a7b4d0c55","incidents","overview","service:api"]}
```

`"*"` invalidates everything. Fetch invalidated resources again. A `: ping` comment
after 25 idle seconds keeps the connection open.

## Metrics

`GET /metrics` returns Prometheus text to a session or a `read` or `write` token:

```yaml title="prometheus.yml"
scrape_configs:
  - job_name: kogut
    scheme: https
    authorization:
      credentials: kg_...
    static_configs:
      - targets: ["kogut.acme.com"]
```

It has `kogut_check_runs_total`, `kogut_ingest_accepted_total`,
`kogut_ingest_rejected_total`, `kogut_agent_unknown_frames_total`,
`kogut_writer_commits_total`, `kogut_agents_connected`,
`kogut_outbox_due`, and `kogut_engine_up`. `kogut_ingest_accepted_total` has an
`endpoint` label: `tracker`, `ping`, `deploy`, `change`, or `agent`.
`kogut_ingest_rejected_total` has a `reason` label: `invalid`, `unknown_site`, `bot`,
`extension` (JavaScript errors from browser extensions), `rate_limited`, or `queue_full`.
