API Reference¶
The REST API lives under /api/v1/. Requests and responses are JSON unless a route says otherwise. Besides the REST API, the server exposes the public ping endpoints (/ping/), the public status page (/status/), the MCP endpoint (/mcp) with its OAuth routes, and the Server-Sent Events streams. Every route is listed on this page.
Conventions¶
Authentication¶
maintenant has no built-in authentication for the dashboard or the REST API. Every /api/ route answers anyone who can reach the listener, so put the instance behind a reverse proxy or an authentication proxy (see Security). Three surfaces are open on purpose:
/ping/{token}: heartbeat pings. The ping token is the only secret./status/: the public status page, its JSON snapshot, its event stream and its subscription links./mcp: protected by its own OAuth 2.0 flow (see MCP and OAuth routes).
The health check /api/v1/health is an ordinary /api/ route, so it is exposed like the others. It reports the version, the runtime and the storage state.
Errors¶
A failed request answers with a JSON body of this shape:
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "The Community edition is limited to 10 endpoints.",
"resource": "endpoints",
"limit": 10,
"required_edition": "personal"
}
}
code and message are always present. The other fields (feature, resource, limit, required_edition, window, max_window) only appear on edition and quota refusals, so a client never has to parse the message.
| Code | Status | Meaning |
|---|---|---|
EDITION_REQUIRED |
403 | The running edition does not open this feature. Carries feature (the capability) and required_edition. A history window the edition does not open also carries window and max_window (the largest window the edition opens). |
QUOTA_EXCEEDED |
403 | A creation would pass the edition cap on endpoints, heartbeat monitors, certificate monitors or status page components. Carries resource, limit and required_edition, the lowest edition that lifts the cap. |
HOST_LIMIT_REACHED |
409 | POST /api/v1/agents/enrollment-tokens when the edition's cap on remote agents is reached (20 on Personal). Same fields as QUOTA_EXCEEDED. |
STORAGE_UNAVAILABLE |
503 | The database is unreachable. Only the routes that read through the store answer this, the others answer INTERNAL_ERROR. Clients should retry. |
INTERNAL_ERROR |
500 | Unexpected failure, including a recovered panic. |
CROSS_ORIGIN_REFUSED |
403 | A browser sent a write (POST, PUT, PATCH, DELETE) from an origin the server does not trust. See Cross-origin writes. |
DEMO_MODE |
403 | The instance runs in demo mode. See Demo mode. |
rate_limited |
429 | The client IP went over a rate limit. Carries a Retry-After header. |
The remaining codes are specific to a route and are listed with it. Most are upper case (INVALID_JSON, NOT_FOUND), but several handlers use lower case codes: webhooks, risk scoring, alert triggers, escalation policies, the status page administration (whose unexpected failures answer internal instead of INTERNAL_ERROR) and personalization, the public status page routes and rate_limited. Codes are given here exactly as the server returns them.
Three answers are not JSON:
- A route the server did not register (a feature that is not wired) and a wrong method on a known path answer the plain-text
404and405of the Go router. - A request still running after 10 seconds is cut with
503and the plain-text bodyrequest timeout. Streams are exempt. The work the request started can still finish, for example a synchronous certificate scan. - The OAuth routes answer with the RFC 6749 shape (
{"error": "...", "error_description": "..."}).
Editions and quotas¶
GET /api/v1/edition returns the edition table the server applies, so a client never needs its own copy. Editions are ordered Community, Personal, Pro.
| Edition | Capabilities opened |
|---|---|
| Community | alert_routing, swarm_dashboard, k8s_cluster, resource_history |
| Personal | Community, plus multihost, cve_enrichment, risk_scoring, changelog, incidents, smtp, alert_advanced_filters, security_posture, ocsp_stapling, telegram |
| Pro | Personal, plus slack, teams, alert_escalation, maintenance_windows, subscribers, personalization |
| Resource | Community | Personal | Pro |
|---|---|---|---|
| Standalone endpoints | 10 | unlimited | unlimited |
| Heartbeat monitors | 5 | unlimited | unlimited |
| Standalone certificate monitors | 5 | unlimited | unlimited |
| Status page components | 3 | unlimited | unlimited |
| Remote agents | 0 | 20 | unlimited |
| History window (resources) | 7 days | 30 days | 90 days |
The caps are read from the running edition at each creation, so a licence change applies without a restart. The tables under each section give the edition a route needs in the Edition column. A dash means the route is open in every edition.
Host scope¶
The local runtime is itself an agent, identified by the sentinel id 00000000-0000-0000-0000-000000000000. Routes that list monitored entities accept an agent_id query parameter: local selects the server's own runtime, any other value selects that agent. Without it, list routes cover every host, with these exceptions: GET /api/v1/resources/summary defaults to the local host, and the single-entity Kubernetes routes (workloads/{id}, pods/{namespace}/{name}) look in the local runtime unless agent_id is given. The parameter is never validated: an unknown agent yields an empty list or a 404.
Limits¶
- Rate limits: per client IP, in token buckets.
/api/allows 50 requests per second (burst 200)./status/,/mcpand/oauth/share a tighter bucket of 10 requests per second (burst 20)./ping/has its own: 60 pings per minute per known token, whatever the address, and 10 per second (burst 20) per address for unknown tokens. Status page subscriptions are limited to 5 per hour. Client addresses behind a reverse proxy are only read from forwarded headers for the proxies listed inMAINTENANT_TRUSTED_PROXIES. - Body size: 1 MiB for
/api/by default (MAINTENANT_MAX_BODY_SIZE), whatever the method. A value that is not a positive whole number of bytes stops the server at startup. Past the limit the JSON decoding fails and the route answers its invalid body error. Public/status/routes accept 4 KiB. - Timeout: 10 seconds for every route except the event streams.
Cross-origin writes¶
The API refuses a browser write that comes from another origin, unless that origin is listed in MAINTENANT_CORS_ORIGINS: the answer is 403 CROSS_ORIGIN_REFUSED. Same-origin requests, requests whose Sec-Fetch-Site is none and requests without Origin and Sec-Fetch-Site headers (curl, scripts) pass. A browser old enough to send no Sec-Fetch-Site header is refused behind a proxy that rewrites the Host header. The wildcard * in MAINTENANT_CORS_ORIGINS allows cross-origin reads but no longer cross-origin writes. /ping/, /status/, /mcp and /oauth/ are not concerned.
Demo mode¶
A demo build of maintenant is read-only. Every request other than GET, HEAD and OPTIONS answers 403 DEMO_MODE (only POST /api/v1/escalation-policies/overlap-probe, which changes nothing, is let through). /ping/, /mcp, /oauth/ and /.well-known/oauth-* answer 403 DEMO_MODE for every method. Outbound heartbeats are never sent. A request carrying the X-Maintenant-Demo-Token header with the value of MAINTENANT_DEMO_TOKEN bypasses the guard. GET /api/v1/edition reports "demo": true.
Health and instance information¶
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/health |
Health check | — |
GET |
/api/v1/runtime/status |
Active runtime and its connection state | — |
GET |
/api/v1/edition |
Edition, features, quotas and tiers | — |
GET |
/api/v1/license/status |
Licence state | — |
Health¶
GET /api/v1/health always answers 200:
{ "status": "ok", "version": "1.11.0", "runtime": { "name": "docker", "connected": true }, "storage": { "engine": "sqlite", "connected": true, "peers": 0 } }
version, runtime and storage are left out when they are not known. The storage object reports the engine backing this instance, whether it answers, and how many other instances beat on the same database. engine is sqlite or postgres. It never carries the connection string, the host or any credential.
This endpoint answers 200 during a database outage
An unreachable database is reported in storage.connected, not in the HTTP
status. /api/v1/health is the target of the Kubernetes liveness and
startup probes: failing it on a blip would restart the instance exactly when
the database needs to be left alone. Reads that need the database answer
503 STORAGE_UNAVAILABLE in the meantime, which clients should ride out
rather than treat as data loss.
Runtime status¶
GET /api/v1/runtime/status returns runtime (docker or kubernetes), context (docker, swarm or kubernetes), connected, label (Containers, Services or Workloads), detected_at and metadata. On a Swarm manager metadata holds cluster_id, is_manager, manager_count, worker_count and service_count. The manager and worker counts come from docker info and are refreshed every 60 seconds. A Swarm worker reports the docker context.
Edition¶
GET /api/v1/edition returns:
| Field | Description |
|---|---|
edition |
community, personal or pro |
organisation_name |
Value of MAINTENANT_ORGANISATION_NAME (default Maintenant) |
status_url |
Public status page URL: the value of MAINTENANT_STATUS_URL, empty when it is not set |
demo |
true in demo mode |
features |
Every capability mapped to a boolean: does the running edition open it. smtp is also false until SMTP is configured |
feature_editions |
Every capability mapped to the lowest edition that opens it |
quotas |
For each capped resource present (agent_hosts, endpoints, heartbeats, certificates, status_components), an object { "used": n, "limit": n }. A limit of -1 means unlimited |
tiers |
The cap of every resource for every edition: { "community": {...}, "personal": {...}, "pro": {...} } |
suspended_channels |
{ "count": n, "channels": [{ "id", "name", "type", "required_edition" }] }: enabled channels the running edition no longer opens |
resource_history |
{ "max_window", "max_window_seconds", "windows": [{ "window", "seconds", "min_edition" }] }. The window catalogue is the same in every edition |
Licence¶
GET /api/v1/license/status always returns the same eight keys: status, edition, plan, message, verified_at, expires_at, updates_until and update_grace_until. Dates are RFC 3339 strings, or an empty string when the licence has none (a perpetual licence has no expires_at). Without a licence key the status is inactive and the edition community. Other statuses include active, grace, expired, revoked, update_window_grace and update_window_ended.
Containers¶
All routes are open in every edition.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/containers |
List containers, grouped |
GET |
/api/v1/containers/{id} |
Get a container with its uptime |
GET |
/api/v1/containers/{id}/transitions |
List state transitions |
GET |
/api/v1/containers/{id}/logs |
Fetch recent logs |
GET |
/api/v1/containers/{id}/logs/stream |
Follow logs in real time (SSE) |
GET |
/api/v1/containers/{id}/uptime/daily |
Daily uptime percentages |
GET |
/api/v1/containers/{id}/endpoints |
List the endpoints of a container |
DELETE |
/api/v1/containers/{id} |
Remove a container from monitoring |
GET /api/v1/containers query parameters: archived=true also returns archived containers, group (a custom or orchestration group name), state (for example running, exited, completed, restarting, paused, created, dead) and agent_id. A container that stopped with exit code 0 or 143 is completed, and so is one that ended with 137 unless the out-of-memory killer sent it: an OOM kill is exited, like any other crash. Containers marked maintenant.ignore are never listed. The answer is { "groups": [{ "name", "source", "containers": [...] }], "total", "archived_count" }, plus "stale": true when the local runtime is disconnected. source is label, namespace, compose, orchestration or default. Each container carries its identity (id, external_id, agent_id, name, image), state, health_status (null without a health check), has_health_check, group and orchestration fields, is_ignored, alert_severity, restart_threshold, archived, timestamps, Kubernetes and Swarm details where they apply, security_insight_count and security_highest_severity. A container reported by a remote agent also carries agent_hostname and agent_label, and stale plus agent_offline when that agent has no live stream.
GET /api/v1/containers/{id} returns the same core fields as one flat object, plus uptime and, for a Kubernetes workload, container_names. uptime holds a percentage for 24h and for every longer window the edition's history cap opens: 7d on Community, plus 30d on Personal and 90d on Pro. Use the daily route for day-by-day history. A Swarm task also carries swarm_service_id, swarm_service_name, swarm_node_id and swarm_task_slot.
GET /api/v1/containers/{id}/transitions query parameters: since and until (RFC 3339; since defaults to 24 hours ago, an invalid value removes the bound), limit (default 50) and offset. The answer is { "container_id", "transitions", "total", "has_more" }, newest first.
GET /api/v1/containers/{id}/logs query parameters: lines (default 100, at most 500) and timestamps=true. The answer is { "container_id", "container_name", "lines", "total_lines", "truncated" }. Logs of a remote container are fetched from its agent (15-second deadline).
GET /api/v1/containers/{id}/logs/stream is a Server-Sent Events stream with its own lines parameter (default 100, at most 500) and, on Kubernetes, container to pick a container of the pod. It sends container.log_line events (container_id, line, stream, timestamp), a keep-alive comment every 25 seconds while the container is silent, and ends with a container.log_error event (container_id, error: container stopped, agent disconnected or the agent's error). The route exists when the local runtime can read logs or an agent can serve them.
DELETE /api/v1/containers/{id} removes the container and its history with a hard delete and answers 204. It emits container.archived.
Errors:
404 CONTAINER_NOT_FOUND: no such container (also on the logs, stream andendpointsroutes).409 CONTAINER_RUNNING: the container is running and cannot be deleted.502 RUNTIME_UNAVAILABLE: the local runtime or agent support is not wired for logs.502 LOGS_UNAVAILABLE: the runtime or the agent could not return the logs.- Logs of a remote container:
503 AGENT_OFFLINE,501 AGENT_TOO_OLD(the agent predates the log command),429 LOGS_BUSY(too many concurrent log requests for that agent) and504 LOGS_TIMEOUT. logsandlogs/streamanswer503 RUNTIME_UNAVAILABLEwhen the local runtime is disconnected.
GET /api/v1/containers/{id}/uptime/daily (and the same route for endpoints and heartbeats) takes days (default 90, at most 365) and answers { "monitor_id", "monitor_type", "days": [{ "date", "uptime_percent", "incident_count" }] }: one entry per UTC day, most recent first. uptime_percent is null for a day without data. Completed days come from the daily aggregates, which are kept for 365 days; the current day is computed from the raw rows. The day of a heartbeat is weighted by the time the monitor was up: a missed deadline counts as down until the next successful ping, and the days before its first ping have no data.
Endpoints¶
Endpoint monitors are discovered from labels or created through the API (a standalone endpoint). All routes are open in every edition, except that creating a standalone endpoint is capped.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/endpoints |
List endpoints | — |
POST |
/api/v1/endpoints |
Create a standalone endpoint (10 on Community) | — |
GET |
/api/v1/endpoints/{id} |
Get an endpoint with its uptime | — |
PUT |
/api/v1/endpoints/{id} |
Update a standalone endpoint | — |
DELETE |
/api/v1/endpoints/{id} |
Delete an endpoint | — |
GET |
/api/v1/endpoints/{id}/checks |
List check results | — |
POST |
/api/v1/endpoints/{id}/check |
Probe now and return the refreshed endpoint | — |
GET |
/api/v1/endpoints/{id}/uptime/daily |
Daily uptime percentages | — |
An endpoint has id, endpoint_type (http or tcp), target, status (up, down, degraded or unknown), alert_state (normal or alerting), the consecutive counters, the last check fields, source (label or standalone), name (present only when source is standalone, on the list, the detail and the creation answer), agent_id and a config object (interval and timeout as duration strings such as 30s, failure_threshold, recovery_threshold, method, expected_status, tls_verify, headers, max_redirects). A new standalone endpoint defaults to a 30-second interval, a 10-second timeout, failure threshold 3, recovery threshold 2, method GET, expected status 2xx, TLS verification on and 5 redirects.
GET /api/v1/endpointsquery parameters:status,container(container name),type,source,include_inactive=trueandagent_id. The answer is{ "endpoints": [...], "total" }.staleandagent_offlinemark an endpoint probed by an agent with no live stream.POST /api/v1/endpointsbody:name(required),target(required; an absolutehttporhttpsURL forhttp, ahost:portaddress with a port from 1 to 65535 fortcp),endpoint_type(required,httportcp),interval(duration, at least 5s, default 30s; an interval below 5s in amaintenant.endpoint.*.intervallabel is raised to 5s),timeout(duration, at least 1s, default 10s, not above the interval),method(defaultGET) andheaders(object). Thresholds, expected status, TLS verification and redirects cannot be set through the API. Answers201with{ "endpoint": {...} }.PUT /api/v1/endpoints/{id}accepts the same fields, all optional; an omitted or empty field keeps its value andheadersreplaces the whole map. The resulting target is validated for the endpoint type as on creation. Only standalone endpoints can be updated.GET /api/v1/endpoints/{id}answers{ "endpoint", "uptime": { "1h", "24h", "7d", "30d" } }.GET /api/v1/endpoints/{id}/checkstakeslimit(default 50, at most 500),offsetandsince(Unix seconds). The answer is{ "endpoint_id", "checks", "total", "has_more" }.POST /api/v1/endpoints/{id}/checkprobes synchronously and answers{ "endpoint", "uptime" }.
Errors:
400 INVALID_JSON,400 INVALID_INPUT(the message names the field),400 NOT_STANDALONE(updating a label-discovered endpoint).403 QUOTA_EXCEEDED: creating past the cap (resource: "endpoints",limit: 10on Community).404 ENDPOINT_NOT_FOUND.409 ENDPOINT_LIVE: deleting an endpoint whose container still runs. It becomes deletable once its container is gone.409 AGENT_PROBED:checkon an endpoint an agent probes itself.409 CHECK_IN_PROGRESS: a check is already running.
Heartbeats¶
All routes are open in every edition, except that creating a heartbeat monitor is capped.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/heartbeats |
List heartbeat monitors | — |
POST |
/api/v1/heartbeats |
Create a heartbeat monitor (5 on Community) | — |
GET |
/api/v1/heartbeats/{id} |
Get a heartbeat monitor and its ping snippets | — |
PUT |
/api/v1/heartbeats/{id} |
Update a heartbeat monitor | — |
DELETE |
/api/v1/heartbeats/{id} |
Delete a heartbeat monitor | — |
POST |
/api/v1/heartbeats/{id}/pause |
Pause deadline checking | — |
POST |
/api/v1/heartbeats/{id}/resume |
Resume deadline checking | — |
POST |
/api/v1/heartbeats/{id}/rotate-token |
Replace the ping token | — |
GET |
/api/v1/heartbeats/{id}/executions |
List executions | — |
GET |
/api/v1/heartbeats/{id}/pings |
List raw pings | — |
GET |
/api/v1/heartbeats/{id}/uptime/daily |
Daily uptime percentages | — |
A heartbeat has id, ping_token (the secret of its ping URL, distinct from the id), name, status (new, up, down, started or paused), alert_state, interval_seconds, grace_seconds, the last ping and deadline fields, the consecutive counters and timestamps. The deadline is the last ping plus the interval plus the grace period.
POST /api/v1/heartbeatsbody:name(required, 1 to 255 characters),interval_seconds(required, 60 to 604800) andgrace_seconds(0 to the interval, default 0). Answers201with the heartbeat object. The cap counts every heartbeat, paused and agent-relayed ones included.PUT /api/v1/heartbeats/{id}takes the same fields, all optional; the grace period must not exceed the resulting interval. Wheninterval_secondsorgrace_secondsis given and the heartbeat isuporstarted,next_deadline_atmoves by the difference between the new and the old interval plus grace; other statuses keep their deadline. Theidand theping_tokennever change.GET /api/v1/heartbeats: query parametersstatusandagent_id; answers{ "heartbeats", "total" }.GET /api/v1/heartbeats/{id}answers{ "heartbeat", "snippets" };snippetsholds ready-to-pastecurl,wget,python,go,bashanddocker_healthchecksnippets onceMAINTENANT_BASE_URLis set.executions(limitdefault 20, at most 500, andoffset) answers{ "executions", "total" }, newest first; each has anoutcomeofsuccess,failure,timeoutorin_progress.pings(limitdefault 50, at most 500, andoffset) answers{ "pings", "total" }withexpected_atandgrace_deadlinecomputed for each ping.rotate-tokenanswers200with the heartbeat and its newping_token; the old token answers404on/ping/from the next request on, and an unknown id answers404 HEARTBEAT_NOT_FOUND.pause,resumeandPUTanswer200with the heartbeat;DELETEanswers204. Deleting is a hard delete: the monitor, its pings, executions, pauses and daily uptime are removed, and its id answers404afterwards on these routes, and its token on/ping/.
Errors:
400 INVALID_JSON,400 INVALID_INPUT(includingheartbeat is already pausedonpauseandheartbeat is not pausedonresume). A ping received while the heartbeat is paused puts it back to monitoring.403 QUOTA_EXCEEDED: creating past the cap (resource: "heartbeats",limit: 5on Community).404 NOT_FOUND: unknown heartbeat, including onDELETE,pauseandresume.
Ping endpoints (public)¶
These routes need no credentials: the ping token of the heartbeat is the secret. A known token is limited to 60 pings per minute, an unknown one counts against the caller's address (10 per second, burst 20). They answer 403 DEMO_MODE in demo mode.
| Method | Endpoint | Description |
|---|---|---|
GET/POST |
/ping/{token} |
Simple ping (success) |
GET/POST |
/ping/{token}/start |
Signal job start |
GET/POST |
/ping/{token}/{exit_code} |
Ping with exit code (0 = success) |
/ping/{token}and/ping/{token}/startanswer200 {"ok": true}./ping/{token}/{exit_code}answers200 {"ok": true, "exit_code": n}. A non-zero exit code raises an alert.- Errors:
404 HEARTBEAT_NOT_FOUND(unknown or deleted heartbeat),400 INVALID_EXIT_CODE(not an integer between 0 and 255, checked before the lookup) and500 INTERNAL_ERROR. - A
POSTbody of up to 10 KiB is read but never stored. - The
source_ipof a ping is the client address resolved like the rate limit does: forwarded headers are believed only when the connection comes from a proxy listed inMAINTENANT_TRUSTED_PROXIES.
Outbound heartbeats¶
Outbound heartbeats make this instance ping an external URL so another system notices when it stops. In demo mode they are read-only like every other route and no ping is sent. All are open in every edition. See Outbound Heartbeats.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/outbound-heartbeats |
List all targets |
POST |
/api/v1/outbound-heartbeats |
Create a target |
PUT |
/api/v1/outbound-heartbeats/{id} |
Replace a target |
DELETE |
/api/v1/outbound-heartbeats/{id} |
Delete a target |
POST |
/api/v1/outbound-heartbeats/{id}/send |
Send a ping now |
A target has id, name, url, interval_seconds, enabled, last_sent_at, last_status_code, last_error, created_at and updated_at. The body of POST and PUT holds name (1 to 255 characters), url (an absolute HTTPS URL that does not resolve to a private or internal address), interval_seconds (30 to 86400) and enabled (default true; PUT replaces the target, so omitting enabled enables it). The list answers { "outbound_heartbeats": [...] }. send works on a disabled target and answers 200 with the refreshed object: a failed send is recorded in last_error and last_status_code, not returned as an HTTP error. Errors: 400 INVALID_JSON, 400 INVALID_INPUT and 404 NOT_FOUND.
Certificates¶
All routes are open in every edition, except that creating a standalone monitor is capped.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/certificates |
List certificate monitors | — |
POST |
/api/v1/certificates |
Create a standalone certificate monitor (5 on Community) | — |
GET |
/api/v1/certificates/{id} |
Get a monitor with its latest check and chain | — |
PUT |
/api/v1/certificates/{id} |
Update a monitor | — |
DELETE |
/api/v1/certificates/{id} |
Delete a monitor | — |
GET |
/api/v1/certificates/{id}/checks |
List check history | — |
POST |
/api/v1/certificates/{id}/check |
Scan now and return the refreshed monitor | — |
A monitor has id, hostname, port, server_name, source (auto, standalone or label), status (valid, expiring, expired, error or unknown), check_interval_seconds, warning_thresholds (days, default [30, 14, 7, 3, 1]), the last check and next check times, agent_id and timestamps.
GET /api/v1/certificatesquery parameters:status,sourceandagent_id. Each monitor carries alatest_checkobject, and the answer is{ "certificates", "total" }.POST /api/v1/certificatesbody:hostname(required),port(default 443),server_name(a bare host name, optional),check_interval_seconds(3600 to 604800, default 43200) andwarning_thresholds. The first TLS check runs before the answer, so201returns{ "certificate", "latest_check" }.PUT /api/v1/certificates/{id}acceptscheck_interval_secondsandwarning_thresholdsonly; other fields are ignored.GET /api/v1/certificates/{id}returns{ "certificate", "latest_check" }wherelatest_checkholds the subject, issuer, SANs, validity dates,days_remaining,chain_valid,chain_error,hostname_match, thechainand, for a stapled response,ocsp_stapled,ocsp_status,ocsp_produced_at,ocsp_next_updateandocsp_error.GET /api/v1/certificates/{id}/checkstakeslimit(default 50, at most 500) andoffsetand answers{ "monitor_id", "checks", "total", "has_more" }. Each check listsocsp_stapledandocsp_statusonly; the other OCSP fields are onlatest_check. A scan pushed by an endpoint probe or by an agent is stored once per monitor interval, or sooner when its outcome changes, so the history holds one row per interval or change.
Errors:
400 INVALID_JSON,400 INVALID_INPUT,400 CANNOT_DELETE_AUTO(an auto-detected monitor is removed by removing its label).403 QUOTA_EXCEEDED(resource: "certificates",limit: 5on Community).404 NOT_FOUND.409 DUPLICATE_MONITOR,409 ALREADY_AUTO_DETECTED,409 AGENT_SCANNED(the agent scans this one itself) and409 CHECK_IN_PROGRESS.
Resources¶
All routes are open in every edition. The history window is capped by the edition.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/containers/{id}/resources/current |
Current CPU, memory, network, I/O | — |
GET |
/api/v1/containers/{id}/resources/history |
Historical metrics (?range=24h; window 30d needs Personal, 90d needs Pro) |
— |
GET |
/api/v1/containers/{id}/resources/alerts |
Get alert thresholds | — |
PUT |
/api/v1/containers/{id}/resources/alerts |
Set alert thresholds | — |
GET |
/api/v1/resources/summary |
Aggregate resource summary of one host | — |
GET |
/api/v1/resources/top |
Top consumers (a period of 30d needs Personal, 90d needs Pro) |
— |
GET |
/api/v1/resources/hosts |
List hosts (local and agents) with current CPU, memory and disk | — |
currentanswerscontainer_id,cpu_percent(100 is one core),mem_used,mem_limit,mem_percent,net_rx_bytes,net_tx_bytes,block_read_bytes,block_write_bytesandtimestamp. It returns404 NOT_FOUNDwhen no sample exists yet. For a container on a remote agent, only a sample received in the last 35 seconds counts.historytakesrange:1h(the default),6h,24h,7d,30dor90d. The answer is{ "container_id", "range", "granularity", "points" }withgranularityofraw,1m,5m,1hor1d. Errors:400 INVALID_RANGEfor an unknown window,403 EDITION_REQUIREDfor a window the edition does not open (withfeature: "resource_history",required_edition,windowandmax_window).alerts(GET) answerscontainer_id,cpu_threshold,mem_threshold,enabled,alert_stateandlast_alerted_at; without configuration it returns 90, 90 andenabled: false.PUTtakescpu_threshold(1 to 1000, required),mem_threshold(1 to 100) andenabled(omitted meansfalse). An alert fires after two consecutive breaching samples, and CPU and memory fire and resolve independently. Errors:400 INVALID_THRESHOLD.summarytakesagent_id(localor absent for the local host) and answers the host totals:agent_id(an empty string for the local host),available,total_cpu_percent,cpu_count,total_mem_used,total_mem_limit,total_mem_percent,total_net_rx_rate,total_net_tx_rate,container_count,disk_total,disk_used,disk_percentandtimestamp. The container count and the network figures cover the containers of that host, including those of a remote agent.total_net_rx_rateandtotal_net_tx_rateare throughputs in bytes per second, computed from the last two samples of each container. A container with a single sample, a counter that went backwards (restart) or a runtime that does not report network counters adds nothing to them.toprequiresmetric(cpuormemory, otherwise400 INVALID_METRIC), and takeslimit(default 5; a larger value is capped at 20),agent_id(absent means every host) andperiod(a history window). The hour or day in progress counts in aperiodranking, not only the closed hours and days. Withoutperiodthe ranking uses the latest samples and is open in every edition. The answer is{ "metric", "period", "consumers": [{ "container_id", "container_name", "value", "percent", "rank" }] }. A badperiodanswers400 INVALID_PERIOD, or403 EDITION_REQUIREDas forhistory.hostsanswers{ "hosts": [{ "agent_id", "hostname", "label", "is_local", "available", "cpu_percent", "mem_used", "mem_total", "mem_percent", "disk_total", "disk_used", "disk_percent", "container_count" }] }, the local host first.
Kubernetes¶
All routes are open in every edition. They are read from the store, per agent, so they also serve clusters monitored by a remote agent. The routes that need metrics-server (workloads/{id}/resources, nodes/{name}/resources) answer 200 with "metrics_available": false and a message when the data is not available, which is always the case for a remote agent.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/kubernetes/namespaces |
List namespaces |
GET |
/api/v1/kubernetes/workloads |
List workloads, grouped by namespace |
GET |
/api/v1/kubernetes/workloads/{id} |
Workload with its pods and events |
GET |
/api/v1/kubernetes/workloads/{id}/resources |
Per-pod CPU and memory from metrics-server |
GET |
/api/v1/kubernetes/pods |
List pods |
GET |
/api/v1/kubernetes/pods/{namespace}/{name} |
Pod with its events |
GET |
/api/v1/kubernetes/nodes |
List nodes |
GET |
/api/v1/kubernetes/nodes/{name}/resources |
Node CPU and memory from metrics-server |
GET |
/api/v1/kubernetes/cluster |
Cluster overview |
- Every route takes
agent_id. workloadstakesnamespaces(comma-separated),kind(Deployment,StatefulSet,DaemonSetorJob) andstatus(healthy,degraded,progressingorfailed). It answers{ "groups": [{ "namespace", "workloads" }], "total" }. A workload hasid(namespace/Kind/name),name,namespace,kind,images,ready_replicas,desired_replicas,statusandcreated_at.{id}is the workload id with its slashes URL-encoded (default%2FDeployment%2Fweb).podstakesnamespaces,workload(id prefix),nodeandstatus(for exampleRunning,Pending).{ "pods", "total" }.clusteranswersnamespace_count,node_count,node_ready_count,pod_status,workload_count,workload_healthy,cluster_health(healthyordegraded) and a per-namespace summary.- Errors:
404 K8S_WORKLOAD_NOT_FOUND,404 K8S_POD_NOT_FOUND,400 INVALID_ID(bad workload id encoding) and500 K8S_ERROR.
Swarm¶
All routes are open in every edition. Services, tasks and nodes are read from the store, per agent. info, dashboard, cluster, update-status and resources read the server's own live Swarm and answer 409 SWARM_NOT_ACTIVE when the local runtime is not a Swarm manager.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/swarm/info |
Swarm state of the local runtime ({ "active": false } when there is none) |
GET |
/api/v1/swarm/services |
List services (?stack=, ?agent_id=) |
GET |
/api/v1/swarm/services/{serviceID} |
Service with its tasks |
GET |
/api/v1/swarm/services/{serviceID}/update-status |
Rolling update state (live) |
GET |
/api/v1/swarm/services/{serviceID}/resources |
Per-task CPU, memory and network (live) |
GET |
/api/v1/swarm/tasks |
List tasks (?service=, ?node=, ?state=, ?agent_id=) |
GET |
/api/v1/swarm/nodes |
List nodes (?agent_id=) |
GET |
/api/v1/swarm/nodes/{nodeID} |
Node with its tasks (live) |
GET |
/api/v1/swarm/dashboard |
Cluster, node and service summary (live) |
GET |
/api/v1/swarm/cluster |
Cluster health and counts (live) |
dashboard answers cluster (counts and task health), nodes and services.
info answers active, cluster_id, is_manager, manager_count, worker_count and created_at. The counts (also in dashboard, cluster and GET /api/v1/runtime/status) and the creation date come from docker info and are refreshed every 60 seconds. The server detects a Swarm as soon as Docker connects, so activating Swarm later needs no restart.
Errors: 404 SWARM_SERVICE_NOT_FOUND, 404 SWARM_NODE_NOT_FOUND, 409 SWARM_NOT_ACTIVE, 409 SWARM_NODES_NOT_AVAILABLE (node monitoring is not available) and 500 INTERNAL_ERROR.
Agents¶
Multi-host agent management. Every route requires Personal or above (capability multihost) and answers 403 EDITION_REQUIRED below that. See Multi-Host Monitoring.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/agents |
List enrolled agents | Personal |
GET |
/api/v1/agents/{id} |
Get an agent | Personal |
PATCH |
/api/v1/agents/{id} |
Update an agent's display label | Personal |
POST |
/api/v1/agents/{id}/revoke |
Revoke an agent (closes its stream, stops retries) | Personal |
DELETE |
/api/v1/agents/{id} |
Delete an agent and purge all its events | Personal |
GET |
/api/v1/agents/metrics |
Fleet counts | Personal |
POST |
/api/v1/agents/enrollment-tokens |
Create a one-time enrollment token | Personal |
GET |
/api/v1/agents/enrollment-tokens |
List enrollment tokens (masked) | Personal |
GET |
/api/v1/agents/enrollment-tokens/{token_id} |
Get an enrollment token (masked) | Personal |
DELETE |
/api/v1/agents/enrollment-tokens/{token_id} |
Delete an enrollment token | Personal |
An agent has agent_id, hostname, label, os_arch, agent_version, detected_runtime (docker, swarm or kubernetes), status (active or revoked), connection_state (connected or disconnected), last_seen_at, created_at, revoked_at, revoked_by, spool and os. spool (queued, draining, dropped_since_connect, reported_at) is null while the agent is disconnected. os holds the host operating system (id, version_id, pretty_name, source, unavailable_reason, reported_at) and its end-of-support state. An agent is connected while its stream is live or it was seen within MAINTENANT_AGENT_STALE_THRESHOLD_SECONDS (60 by default).
GET /api/v1/agentstakesstatus(active,revokedorall) andconnection_state(connectedordisconnected); the answer is{ "agents": [...] }.PATCHtakes{ "label": "..." }(required, up to 64 characters, empty clears it) and answers the updated agent.revokeanswers the agent anddeleteanswers204.POST /api/v1/agents/enrollment-tokenstakes an optional{ "ttl_hours": n }(default 24, at most 168). The201answer carriestoken_id,token(shown only here),token_masked,created_at,expires_at,install_templates(standalone,docker_run,docker_compose,kubernetes) andwarnings(for examplepublic_url_appears_local,public_url_plaintext_refused).GET .../enrollment-tokenslists unconsumed, unexpired tokens;include_expired=trueandinclude_consumed=truewiden the list. The answer is{ "tokens": [...] }.GET /api/v1/agents/metricsanswerstotal,by_status,by_runtime,by_connection_stateandtotal_events_per_second_observed_5m.
Errors: 404 NOT_FOUND, 400 INVALID_JSON, 400 MISSING_FIELD, 400 LABEL_TOO_LONG, 409 TOKEN_CONSUMED (deleting a consumed token) and 409 HOST_LIMIT_REACHED (creating a token when the cap is reached: 20 active remote agents on Personal, the error names pro as the edition that lifts it).
Alerts¶
All routes are open in every edition.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/alerts |
List alerts (including resolved), newest first |
GET |
/api/v1/alerts/active |
List active alerts, by severity |
GET |
/api/v1/alerts/{id} |
Get an alert |
POST |
/api/v1/alerts/{id}/acknowledge |
Acknowledge an active alert |
GET /api/v1/alertstakessource,severityandstatus(active,resolvedorsilenced),before(RFC 3339; returns alerts fired before it),before_idandlimit(1 to 200, default 50). The answer is{ "alerts": [...], "has_more": bool }, ordered byfired_atthenid, newest first. To page, pass thefired_atand theidof the last alert asbeforeandbefore_id: alerts fired in the same second as the cursor are neither skipped nor repeated.before_idwithoutbeforeis refused. Errors:400 INVALID_PARAM.GET /api/v1/alerts/activeanswers{ "critical": [...], "warning": [...], "info": [...] }. Acknowledged and resolved alerts are left out.GET /api/v1/alerts/{id}answers404 NOT_FOUNDfor an unknown alert.- A daily job purges the alerts that are not active (resolved or silenced) once they are older than 90 days, counted from their resolution, or from their creation when they never resolved. An alert that is still active is never purged, however old.
- An alert has
id,source,alert_type,severity(critical,warningorinfo),status,message,entity_type,entity_id,entity_name,agent_id(the host the alert belongs to: the agent that runs the container, endpoint, heartbeat or certificate, the disconnected agent itself, or00000000-0000-0000-0000-000000000000for the server's own runtime and for alerts with no host, such as the infrastructure security score),details(a string holding JSON),fired_at,resolved_at,resolved_by_id,acknowledged_at,acknowledged_by,escalated_at(set when an escalation policy notifies a level for the alert) andcreated_at. POST .../acknowledgetakes{ "acknowledged_by": "..." }(required). It answers the alert, emitsalert.acknowledgedand stops its running escalation. The dashboard, the MCP server and the security posture acknowledgments all go through this one path, so they behave the same. An acknowledged alert whose severity later rises does not start a new escalation. Errors:400 INVALID_BODY,400 INVALID_REQUEST,404 NOT_FOUND(unknown alert) and409 CONFLICT(the alert is not active or is already acknowledged).
Notification Channels¶
Channels are silent by default. They only fire when referenced by an Alert Trigger or an Escalation Policy. Every route is open, but the channel type needs an edition.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/channels |
List notification channels |
POST |
/api/v1/channels |
Create a channel |
PUT |
/api/v1/channels/{id} |
Update a channel |
DELETE |
/api/v1/channels/{id} |
Delete a channel |
POST |
/api/v1/channels/{id}/test |
Send a test notification |
Channel type |
Destination (url) |
Edition |
|---|---|---|
webhook (default) |
HTTPS URL | — |
discord |
Discord webhook URL | — |
email |
Recipient address (needs MAINTENANT_SMTP_*) |
Personal |
telegram |
Chat id (-1001234567890) or public @username |
Personal |
slack |
Slack webhook URL | Pro |
teams |
Teams webhook URL | Pro |
A channel has id, name, type, url, headers (a string holding a JSON object), config (a string holding JSON, for example {"thread_id": "42"} on Telegram or {"subject_label": "prod-paris"} on email), has_secret, has_headers, has_url_secret, enabled, health (healthy or failing, on the list only), suspended, required_edition and timestamps. Secrets never come back, on the list, in the POST and PUT answers or in the channel.created and channel.updated SSE events:
- The secret (a Telegram bot token) is never returned, only
has_secret. - Header values are replaced by
***:headerscomes back as the same names, sorted, for example{"Authorization":"***","X-Trace":"***"}, withhas_headers: true. Without any header,headersis absent andhas_headersisfalse. - The
urlof aslack,discordorteamschannel is cut after the host (https://hooks.slack.com), withhas_url_secret: true. Other types return theirurlwhole, withhas_url_secret: false.
Deliveries always use the stored values.
POSTbody:name(required),url(required),type,headers,secret(required for Telegram),configandenabled(defaulttrue). A URL must be HTTPS and must not resolve to a private or internal address, unlessMAINTENANT_ALLOW_PRIVATE_WEBHOOKSis set.headersis empty or a string holding a JSON object whose names are not empty and whose values are all strings, such as"{\"Authorization\":\"Bearer x\"}"; anything else answers400 INVALID_INPUTand nothing is written. Answers201with the stored channel, masked, with its timestamps.PUTtakes the same fields, all optional, and answers200with the stored channel, masked, with its timestamps. An absentheadersorurlkeeps the stored value, so the masked values never need to be sent back;headers: "{}"or""clears the headers. A secret cannot be cleared. A request that only setsenabledtofalseis always accepted, even after the edition dropped.- A
webhookchannel receives{ "event": "alert.fired" | "alert.resolved", "alert": {...}, "timestamp": "..." }.alertholds the fields of thealert.firedSSE event, plusurl, the link to the alert in the UI (<MAINTENANT_BASE_URL>/alerts/history?alert=<id>).urlis absent when the base URL has no host a recipient could open, as with the default built from a listen address such as:8080or0.0.0.0:8080. testsends a fake alert through the channel. Awebhookchannel receives{"event":"test","alert":{"id":"","source":"test","alert_type":"test","severity":"info","status":"active","message":"maintenant test notification","entity_type":"test","entity_id":"","entity_name":"test","agent_id":"00000000-0000-0000-0000-000000000000","fired_at":"...","created_at":"..."},"timestamp":"..."}, withouturlsince the alert has no id; the other types receive their usual message built from the same alert, exceptemail, whose subject is always[<subject_label or maintenant>] TEST: Test notification. Anemailchannel putsconfig.subject_label(at most 64 characters, no control character, otherwise400 VALIDATION_ERROR) between the brackets of every subject.testanswers404 NOT_FOUNDfor an unknown channel. Otherwise it answers200:{ "status": "delivered", "response_code": n }or{ "status": "failed", "error": "..." }.- Creating, updating or testing a channel of a type the edition does not open answers
403 EDITION_REQUIRED(featureis the capability:slack,teams,smtportelegram). After a downgrade such a channel issuspended: it stops delivering andGET /api/v1/editionlists it undersuspended_channels. - Errors:
400 INVALID_BODY,400 INVALID_INPUT(malformedheaders),400 VALIDATION_ERROR,404 NOT_FOUNDand409 DUPLICATE_NAMEon create.
Alert Triggers¶
Triggers route alerts to channels based on filters. A trigger matches when all of its non-empty filters match.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/alert-triggers |
List all triggers |
POST |
/api/v1/alert-triggers |
Create a trigger |
GET |
/api/v1/alert-triggers/{id} |
Get trigger details |
PUT |
/api/v1/alert-triggers/{id} |
Update a trigger |
DELETE |
/api/v1/alert-triggers/{id} |
Delete a trigger |
A trigger has id, name, filter_severities, filter_sources and filter_scopes (comma-separated strings, empty matches everything; a scope is entity_type:entity_id), enabled, notify_on_resolve, channel_ids and timestamps. The list answers { "triggers": [...] }.
POSTbody:name(required, up to 120 characters),channel_ids(required, at least one existing channel), the three filters,enabled(defaulttrue) andnotify_on_resolve(defaulttrue, set it tofalsefor a channel that should only receive failures).PUTreplaces the name, the filters and the channels;enabledandnotify_on_resolvekeep their value when omitted.filter_scopesrequires Personal (capabilityalert_advanced_filters). Below Personal, a non-empty value that differs from the stored one answers403 EDITION_REQUIRED; keeping or clearing the stored value is allowed.filter_severitiesandfilter_sourcesare open in every edition.- Errors (lower case codes):
400 validation_failed,404 trigger_not_found,409 name_conflict. Invalid JSON answers400 INVALID_BODY.
Escalation Policies¶
Multi-level escalation chains for unacknowledged alerts. Every route requires Pro (capability alert_escalation) and answers 403 EDITION_REQUIRED below it.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/escalation-policies |
List policies (?active=true keeps the active ones) |
Pro |
POST |
/api/v1/escalation-policies |
Create a policy | Pro |
GET |
/api/v1/escalation-policies/{id} |
Get a policy | Pro |
PUT |
/api/v1/escalation-policies/{id} |
Replace a policy | Pro |
PATCH |
/api/v1/escalation-policies/{id}/active |
Activate or deactivate | Pro |
DELETE |
/api/v1/escalation-policies/{id} |
Delete a policy | Pro |
POST |
/api/v1/escalation-policies/overlap-probe |
Detect overlapping policies | Pro |
GET |
/api/v1/escalation-policies/{id}/runs |
List recent runs of a policy | Pro |
GET |
/api/v1/alerts/{alert_id}/escalation-runs |
List the runs of an alert | Pro |
GET |
/api/v1/escalation-runs/{run_id} |
Get a run with its deliveries | Pro |
A policy is { "name", "active", "filters": { "severities", "scopes": [{ "kind", "ref_id" }] }, "levels": [{ "delay_seconds", "channel_ids" }] }. Validation: a name of 1 to 120 characters, one to 5 levels, each delay between 60 and 86400 seconds and at least 60 seconds after the previous level, and at least one channel per level. active has no default: leaving it out creates or replaces the policy as inactive. The delays of the levels count from the start of the run.
GET /api/v1/escalation-policiesanswers{ "policies": [...], "limits": { "max_active", "max_levels", "current_active" } }.max_activeis-1(no cap on active policies) andmax_levelsis 5.overlap-probetakes a policy body and answers{ "overlapping": [{ "policy_id", "policy_name", "shared_channels", "filter_intersection" }] }: policies whose filters intersect and that share at least one channel.PATCH .../activetakes{ "active": bool }and answers{ "id", "active", "updated_at" }.runstakeslimit(default 50, at most 200) andcursor(a run id; returns older runs) and answers{ "runs": [...] }. A run has astatus(active,paused_by_maintenance,stopped_by_ack,stopped_by_resolution,stopped_by_policy_deletion,stopped_by_policy_disabled,stopped_by_edition_downgradeorexhausted); its deliveries have astatusofpending,sent,failedorabandoned. Deactivating a policy (PUTwithactivefalse, orPATCH .../active) stops its running runs withstopped_by_policy_disabled.- Errors (lower case codes):
400 validation_failed,400 invalid_body,404 policy_not_found,404 run_not_foundand500 internal_error.
Silence Rules¶
All routes are open in every edition.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/silence |
List silence rules (?active=true keeps the ones in effect) |
POST |
/api/v1/silence |
Create a silence rule |
DELETE |
/api/v1/silence/{id} |
Cancel a silence rule |
POSTbody:duration_seconds(required, above zero) and optionalentity_type,entity_id,sourceandreason. All three filters empty silence everything; otherwise an alert must match every filter that is set. Answers201with the rule.- The list answers
{ "rules": [...] }. A rule hasid,entity_type,entity_id,source,reason,starts_at,duration_seconds,expires_at,is_active,cancelled_atandcreated_at. DELETEcancels the rule (is_activebecomesfalse, the rule stays listed) and answers204, even for an unknown id.- Errors:
400 INVALID_BODY,400 VALIDATION_ERROR.
Webhooks¶
Webhook subscriptions deliver raw events to a URL. To notify a chat service, use a channel instead: a webhook URL of Discord, Slack or Teams is refused. All routes are open in every edition.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/webhooks |
List webhook subscriptions |
POST |
/api/v1/webhooks |
Create a webhook subscription |
DELETE |
/api/v1/webhooks/{id} |
Delete a webhook subscription |
POST |
/api/v1/webhooks/{id}/test |
Send a test payload |
POSTbody:name(required, 1 to 100 characters),url(required, HTTPS, not resolving to a private or internal address unlessMAINTENANT_ALLOW_PRIVATE_WEBHOOKSis set),secret(optional, enables the signature) andevent_types(default["*"]). Valid event types are*,container.state_changed,endpoint.status_changed,heartbeat.status_changed,certificate.status_changed,alert.firedandalert.resolved. Answers201with the subscription (the secret is never returned).- The list answers
{ "webhooks": [...] }. A subscription hasid,name,url,event_types,is_active,last_delivery_status(deliveredorfailed),last_delivery_at,failure_count(consecutive failures) andcreated_at. - After 10 consecutive failed deliveries the subscription is deactivated (
is_activebecomesfalse). A successful delivery, a test included, resetsfailure_countand reactivates it. testsends{"type": "test", "timestamp": "...", "data": {"message": "maintenant webhook test"}}synchronously, with the headers and the signature of a real delivery, and records the outcome like one. It answers200with{ "status": "delivered", "http_status": n }or, on failure,{ "status": "failed", "error": "..." }plushttp_statuswhen the target answered.- Errors use lower case codes:
400 invalid_json,400 invalid_input,404 not_foundand500 internal_error.
Delivery format¶
Each delivery is a POST with a JSON body of the shape {type, timestamp, data}, where data is the raw event payload for that type:
{
"type": "container.state_changed",
"timestamp": "2026-07-19T19:49:42Z",
"data": {
"id": "a1b2c3d4e5f6",
"state": "running",
"previous_state": "exited",
"health_status": "healthy",
"exit_code": 0,
"agent_id": "00000000-0000-0000-0000-000000000000"
}
}
The data fields are those of the SSE event of the same type. A subscription to container.state_changed also receives the container.discovered payloads, under that type, and a subscription to endpoint.status_changed also receives the endpoint.discovered and endpoint.removed payloads. Subscribe to specific types or * for all. A delivery is attempted up to three times, with a 1 second then a 5 second wait between attempts. The events sent to one URL are delivered one at a time in the order they happened, so a retry never lets a later event overtake an earlier one.
Headers on every delivery:
| Header | Value |
|---|---|
X-maintenant-Event |
the event type |
X-maintenant-Delivery |
a unique delivery UUID |
X-maintenant-Signature |
sha256=<hmac>, present only when the subscription has a secret |
When a secret is set, verify authenticity by computing HMAC-SHA256(secret, raw_request_body) and comparing (constant-time) against the hex digest in X-maintenant-Signature. The signature is computed over the exact bytes of the request body.
Container recreation is noisy
A docker compose up -d that recreates a stack legitimately produces several container.state_changed deliveries per service (the old container goes running → exited, the new one created → running, plus health transitions). Filter on data.state / data.previous_state if you only care about specific transitions.
Status Page (Admin)¶
The admin routes of the public status page. See Status Page. The routes below sit under /api/v1/status/; the public page is served under /status/ (see Public status page).
Components¶
All routes are open in every edition, except that creating a component is capped.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/status/components |
List components | — |
POST |
/api/v1/status/components |
Create a component (3 on Community) | — |
PUT |
/api/v1/status/components/{id} |
Update a component | — |
DELETE |
/api/v1/status/components/{id} |
Delete a component | — |
The list is a bare JSON array (null when empty), ordered by display_order. A component has id, composition_mode (explicit or match-all), monitors ([{ "type", "id" }]), match_all_type, display_name, display_order, visible, derived_status, status_override, effective_status, auto_incident, needs_attention and timestamps. Statuses are operational, degraded, partial_outage, major_outage and under_maintenance.
POSTbody:display_name(required),composition_mode(defaultexplicit),monitors(anexplicitcomponent needs at least one, each of typecontainer,endpoint,heartbeatorcertificate),match_all_type(required inmatch-allmode, empty otherwise),display_order,visible(defaulttrue) andauto_incident.403 QUOTA_EXCEEDEDwhen the cap is reached (resource: "status_components",limit: 3on Community).PUTtakes the same fields, all optional.composition_modeandmatch_all_typecannot change, amatch-allcomponent's monitors cannot be edited, andstatus_overrideset to an empty string clears the override. Astatus_overridemust be one of the five statuses above, otherwise the answer is400 validation. While a maintenance window runs it forcesunder_maintenanceon its components but remembers the override an operator had set: that override comes back when the last window holding the component ends.DELETEanswers204.- Creating, updating or deleting a component emits
status.component_created,status.component_updatedorstatus.component_deletedon the dashboard stream, and on the public stream followed bystatus.global_changedwhen the component is visible (or was visible before the update or delete), so open pages refresh. A hidden component never reaches the public stream. - Errors (lower case codes):
400 invalid_body,400 validation,404 not_foundand500 internal.
Incidents¶
Reading is open in every edition. Writing requires Personal (capability incidents).
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/status/incidents |
List all incidents | — |
POST |
/api/v1/status/incidents |
Create an incident | Personal |
PUT |
/api/v1/status/incidents/{id} |
Update an incident's title, severity or components | Personal |
DELETE |
/api/v1/status/incidents/{id} |
Delete an incident | Personal |
POST |
/api/v1/status/incidents/{id}/updates |
Add an incident update | Personal |
GETtakesstatus,severity,limit(1 to 100, default 20) andoffset, and answers{ "incidents": [...], "total" }. An incident hasid,title,severity,status,is_maintenance,components,updatesand timestamps.POSTbody:titleandseverity(minor,majororcritical) are required;status(investigating,identified,monitoringorresolved) defaults toinvestigating;component_idsandmessage(the first update) are optional. It emitsstatus.incident_createdand emails the confirmed subscribers when subscriptions are open.PUTtakestitle,severityandcomponent_ids, all optional. Leavingcomponent_idsout keeps the linked components, and an empty list removes them.POST .../updatestakesstatus(one of the four statuses above) andmessage(both required). An update with statusresolvedresolves the incident and emitsstatus.incident_resolved; any other update emitsstatus.incident_updated.- A
component_idslist that holds an empty, unknown or repeated id is refused with400 validationbefore anything is written, on create and on update. - Errors (lower case codes):
400 validation(a missing field, a value outside the lists above or an invalid component id),404 not_found.
Maintenance windows¶
Reading is open in every edition. Writing requires Pro (capability maintenance_windows).
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/status/maintenance |
List maintenance windows | — |
POST |
/api/v1/status/maintenance |
Schedule a maintenance window | Pro |
PUT |
/api/v1/status/maintenance/{id} |
Update a maintenance window | Pro |
DELETE |
/api/v1/status/maintenance/{id} |
Delete a maintenance window | Pro |
GETtakesstatus(upcoming,activeorcompleted) andlimit(1 to 100, default 20) and answers a bare array (nullwhen empty). Withoutstatus, running windows come first, then upcoming ones (soonest first), then completed ones (latest first).POSTbody:title,starts_atandends_at(RFC 3339,ends_atnot beforestarts_at) are required;descriptionandcomponent_idsare optional. An empty, unknown or repeated component id answers400 validation.PUTtakes the same fields, all optional (leavingcomponent_idsout keeps the linked components), and answers400 validationwhen the resultingends_at(the new one, or the stored one when it is left out) is beforestarts_at, and409 conflictwhile the window is active. A scheduler checks the windows every 60 seconds: starting one opens a "Scheduled Maintenance" incident, puts its components under maintenance and emitsstatus.maintenance_started; ending it emitsstatus.maintenance_ended. When several windows hold the same component, it stays under maintenance until the last one ends.DELETEon a running window ends it the way its scheduled end would: the incident is resolved, the components are released andstatus.maintenance_endedis emitted.
Subscribers¶
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/status/subscribers |
List email subscribers (addresses masked) | Pro |
Answers { "subscribers": [{ "id", "email", "confirmed", "created_at" }], "total", "confirmed" }.
SMTP test¶
Status page emails are sent through the SMTP server configured with the MAINTENANT_SMTP_* variables. There is no route to read or change that configuration.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
POST |
/api/v1/status/smtp/test |
Send a test email | Personal |
Body: { "to": "address" }. Answers 200 {"status": "sent"}. Errors: 400 not_configured (no SMTP host), 400 invalid_body, 400 validation (invalid address) and 502 smtp_failed (the message is the SMTP error).
Personalization¶
Branding of the public page. Every route requires Pro (capability personalization), reads included, and answers 403 EDITION_REQUIRED below it. Errors use the codes validation_error, payload_too_large, unsupported_mime, active_svg, not_found and internal_error.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/status-page/settings |
Get the settings | Pro |
PUT |
/api/v1/status-page/settings |
Replace the settings | Pro |
GET |
/api/v1/status-page/assets/{role} |
Download an asset | Pro |
PUT |
/api/v1/status-page/assets/{role} |
Upload an asset (multipart, part file, optional alt_text) |
Pro |
DELETE |
/api/v1/status-page/assets/{role} |
Delete an asset | Pro |
GET |
/api/v1/status-page/footer-links |
List footer links | Pro |
POST |
/api/v1/status-page/footer-links |
Add a footer link | Pro |
PUT |
/api/v1/status-page/footer-links/order |
Reorder footer links ({ "ids": [...] }) |
Pro |
PUT |
/api/v1/status-page/footer-links/{id} |
Update a footer link | Pro |
DELETE |
/api/v1/status-page/footer-links/{id} |
Delete a footer link | Pro |
GET |
/api/v1/status-page/faq |
List FAQ items | Pro |
POST |
/api/v1/status-page/faq |
Add an FAQ item | Pro |
PUT |
/api/v1/status-page/faq/order |
Reorder FAQ items ({ "ids": [...] }) |
Pro |
PUT |
/api/v1/status-page/faq/{id} |
Update an FAQ item | Pro |
DELETE |
/api/v1/status-page/faq/{id} |
Delete an FAQ item | Pro |
- Settings (
PUTreplaces every field):title(1 to 100 characters),subtitle(200),colors(bg,surface,border,text,accent,status_operational,status_degraded,status_partial,status_major, each#RRGGBBor#RRGGBBAA),announcement(enabled,message_mdup to 1000 characters,urlover HTTP or HTTPS),footer_text_md(500),locale(enorfr),timezone(an IANA name or empty) anddate_format(relativeorabsolute). Markdown is rendered to sanitized HTML on the server. The answer addsversionand, when the palette misses WCAG AA contrast, awarnings.contrastlist that never blocks the save. - Assets:
roleislogo(200 KiB; PNG, JPEG, WebP or SVG),favicon(50 KiB; PNG, ICO or SVG) orhero(500 KiB; PNG, JPEG or WebP). The type is sniffed from the content. An SVG is read whole and needs no XML declaration, but one that carries a script, an event handler, ajavascript:link, aforeignObjector embedded HTML is refused. An oversized file answers400 payload_too_large, a wrong type400 unsupported_mimeand an SVG with active content400 active_svg. - Footer links take
label(1 to 60 characters) andurl(HTTP or HTTPS). FAQ items takequestion(1 to 200 characters) andanswer_md(up to 4000). Lists answer{ "items": [...] }.
Public status page¶
These routes need no credentials, are not under /api/, share the 10 requests per second bucket of the public surfaces and cap bodies at 4 KiB. The page can be framed from any origin. See Status Page.
| Method | Endpoint | Description |
|---|---|---|
GET |
/status/ |
The status page (the dashboard application at the status route). /status redirects to it |
GET |
/status/api |
JSON snapshot of the current status |
GET |
/status/events |
Event stream of the status page (SSE) |
GET |
/status/feed.atom |
Atom feed of the ongoing incidents and of those resolved in the last 30 days |
GET |
/status/settings.json |
Branding for the page: colors, assets, footer, FAQ |
POST |
/status/subscribe |
Subscribe an email address to updates |
GET |
/status/confirm?token= |
Confirm a subscription |
GET |
/status/unsubscribe?token= |
Unsubscribe |
GET /status/apianswersglobal_status,global_message,updated_at,components([{ "id", "name", "status", "monitors" }], visible components only;monitorsis[{ "type", "id", "name", "status" }]),active_incidents,upcoming_maintenance(the next five windows, soonest first),subscriptions_enabledand, once the page has been personalized,personalization_version. List fields arenullwhen empty. It sendsAccess-Control-Allow-Origin: *.- Incident and maintenance
componentslists on the public surfaces (this route, the stream, the Atom feed and the notification emails) name visible components only: a hidden component is never named, and an incident opened automatically by a hidden component's alert is not created. The Atom feed builds its links fromMAINTENANT_STATUS_URL, or fromMAINTENANT_BASE_URLfollowed by/statuswhen it is not set, never from theHostheader of the request. GET /status/settings.jsonreturns the branding with images inlined asdata:URLs (there is no public asset URL). Below Pro it returns the default settings. It sends anETag("v<version>"), answers304to a matchingIf-None-Matchand sendsAccess-Control-Allow-Origin: *.POST /status/subscribetakes{ "email": "..." }(Content-Type: application/json) or a form fieldemail(application/x-www-form-urlencoded). Any other content type answers415 unsupported_media_type. Subscriptions are open when SMTP is configured and the edition is Pro; otherwise the route answers503 subscriptions_unavailable. The answer is always200 {"status": "confirmation_sent"}, whether the address is new, pending or already confirmed, so nothing reveals who is subscribed. A pending address gets a new link, which replaces the old one, and a confirmed address gets no mail. Errors:400 invalid_email,400 invalid_body,413 body_too_large,429 rate_limited(5 per hour and per IP, withRetry-After) and500 subscription_failed.confirmandunsubscribeanswer small HTML pages. The link in the confirmation mail is valid for 24 hours.confirmanswers503while subscriptions are closed;unsubscribekeeps working.- Plain-text errors on the page, API and feed routes:
503 Status page not availablewhen the application is not embedded and500 Internal Server Error.
Updates¶
All routes are open in every edition. Two response fields depend on the edition: cve_counts in the summary and the changelog and CVE fields of a container's update (Personal).
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/updates |
List updates (?status=&update_type=) |
GET |
/api/v1/updates/summary |
Update summary with counts (os_counts for host operating systems) |
GET |
/api/v1/updates/hosts |
Every monitored host with its OS identity and end-of-support status, plus the support table in use |
POST |
/api/v1/updates/scan |
Trigger a manual scan |
GET |
/api/v1/updates/scan/{scan_id} |
Get scan status |
GET |
/api/v1/updates/dry-run |
Dry run: list the available updates |
GET |
/api/v1/updates/container/{container_id} |
Update details for a container |
POST |
/api/v1/updates/pin/{container_id} |
Pin the current version |
DELETE |
/api/v1/updates/pin/{container_id} |
Unpin the version |
GET |
/api/v1/updates/exclusions |
List exclusions |
POST |
/api/v1/updates/exclusions |
Create an exclusion |
DELETE |
/api/v1/updates/exclusions/{id} |
Delete an exclusion |
{container_id} is the container's external id (the runtime id; for Kubernetes namespace/Kind/name, slashes included).
GET /api/v1/updatestakesstatus(availableorpinned) andupdate_type(major,minor,patch,digest_onlyorunknown). The answer is{ "updates": [...], "last_scan", "next_scan" }(both dates are the zero time until a scan finishes). An update hasid,container_id,container_name,image,current_tag,current_digest,latest_tag,latest_digest,update_type,risk_score,status,detected_at, andpin_reasonwhen pinned.risk_score(Personal and above) adds up at most 75 points: update type (up to 20), CVE severity (30), network exposure (10, from the container's security insights), restarts over the last 24 hours (10) and breaking changes (5). Container criticality and dependents no longer count.summaryanswerslast_scan,next_scan,scan_status(running,completed,failedoridle),counts(critical,recommended,available,up_to_date,pinned),cve_counts(Personal) andos_counts(ended,ending_soon,unknown,untracked,supported).hostsanswers{ "hosts": [...], "eol_table": { "source", "fetched_at", "refresh_enabled", "last_refresh_error" } }. A host hasagent_id,hostname,label,is_local,runtime,connection_stateand anosobject with itssupportstate (unknown,untracked,supported,security_only,ending_soonorended).scananswers202 {"status": "running", "started_at": "..."}without a scan id: the id arrives in theupdate.scan_startedevent.409 SCAN_IN_PROGRESSwhen a scan is running.scan/{scan_id}answersscan_id,status,started_at,containers_scanned,updates_found,errorsandcompleted_at;404 NOT_FOUNDfor an unknown scan.dry-runanswers{ "would_update": [{ "container_id", "container_name", "image", "current_tag", "latest_tag", "update_type" }] }, the updates with statusavailable.container/{container_id}addspinned,pin_reason,update_command,rollback_command,tag_includeandtag_exclude. The commands are shell commands that depend on the workload: Compose or standalone Docker commands,docker service update --image <reference> <service>for a Swarm task,kubectl set imagefor a Kubernetes workload or a bare pod. For a Swarm task or a Kubernetes workload whose tag was republished under the same name, the command sets<tag>@<digest>, because setting an unchanged reference rolls nothing out. On Personal and above it also carriessource_url,previous_digest,changelog_url,changelog_summary,has_breaking_changesandactive_cves. Errors:404 NOT_FOUND.pintakes an optional{ "reason": "..." }and answers200 { "container_id", "pinned_tag", "pinned_digest", "reason", "pinned_at" };404 NOT_FOUNDwhen the container has no update data.DELETEalways answers204.exclusions(POST) takespattern(required) andpattern_type(imageortag), and answers201. Posting a pattern that already exists answers200with the stored exclusion. Errors:400 INVALID_JSON,400 INVALID_PATTERN,400 INVALID_TYPE.
Security¶
Insights are open in every edition.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/security/insights |
List all insights, by container | — |
GET |
/api/v1/security/insights/{container_id} |
Insights of a container | — |
GET |
/api/v1/security/summary |
Aggregated counts by severity and type | — |
The list answers { "containers": [...], "summary": {...} } and the summary route answers the same summary object (total_containers_monitored, total_containers_affected, total_insights, by_severity, by_type). An insight has type (port_exposed_all_interfaces, database_port_exposed, privileged_container, host_network_mode, service_load_balancer or service_node_port), severity (critical, high or medium), container_id, container_name, title, description, details and detected_at. {container_id} is the maintenant container id; an unknown one answers 404 CONTAINER_NOT_FOUND.
Security Posture¶
Requires Personal (capability security_posture). Every route answers 403 EDITION_REQUIRED below it.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/security/posture |
Global infrastructure posture score | Personal |
GET |
/api/v1/security/posture/containers |
Per-container posture scores (?limit=&offset=) |
Personal |
GET |
/api/v1/security/posture/containers/{container_id} |
Posture score for one container | Personal |
POST |
/api/v1/security/acknowledgments |
Acknowledge a finding | Personal |
GET |
/api/v1/security/acknowledgments |
List acknowledgments (?container_id=) |
Personal |
DELETE |
/api/v1/security/acknowledgments/{id} |
Revoke an acknowledgment | Personal |
- The global score has
score,color(greenfrom 80,yellowfrom 60,orangefrom 40, elsered),container_count,scored_count,is_partial,categories(tls,cves,updates,network_exposure,image_age),top_risksandcomputed_at. Every scoring of the infrastructure evaluates the posture threshold alert (MAINTENANT_SECURITY_SCORE_THRESHOLD): a read of this route, the MCP posture tool, and, when a threshold is set, a background check every 5 minutes. containerstakeslimit(default 50) andoffsetand answers{ "containers", "total", "limit", "offset" }, worst score first. All containers are scored in one batch (certificates and updates are read once, not once per container); a scoring failure answers500 INTERNAL_ERRORinstead of dropping containers from the list.POST acknowledgmentstakescontainer_idandfinding_type(required),finding_key,acknowledged_byandreason. Answers201. Errors:400 INVALID_BODY,400 MISSING_FIELDS,404 NOT_FOUND,409 ALREADY_ACKNOWLEDGED.
CVE Intelligence¶
Requires Personal (capability cve_enrichment). The check is made by the handler, which answers 403 EDITION_REQUIRED below it.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/cve |
List known CVEs across all containers (?severity=&container_id=) |
Personal |
GET |
/api/v1/cve/{container_id} |
List CVEs for a specific container | Personal |
{container_id} is the external id. The list answers { "cves": [{ "cve_id", "cvss_score", "severity", "summary", "first_detected_at", "affected_containers" }], "total", "by_severity" }; total and by_severity count container-CVE pairs, not distinct CVEs. The container route answers { "container_id", "cves": [...] }.
Risk Scoring¶
Requires Personal (capability risk_scoring). Every route answers 403 EDITION_REQUIRED below it. Error codes here are lower case.
| Method | Endpoint | Description | Edition |
|---|---|---|---|
GET |
/api/v1/risk |
Risk scores for all containers | Personal |
GET |
/api/v1/risk/{container_id} |
Risk score of a container; with ?period=24h\|7d\|30d, its score history |
Personal |
The list answers { "containers": [{ "container_id", "container_name", "risk_score", "level" }], "host_risk_score", "host_risk_level" }. level is critical from 81, high from 61, moderate from 31, else low. With period, the container route answers { "container_id", "history": [{ "score", "recorded_at" }] }. Errors: 400 invalid_period, 404 not_found (no risk data), 500 internal.
Dashboard¶
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/dashboard/sparklines |
Response times of the last 20 checks of every active endpoint |
Answers an object keyed endpoint:<endpoint_id>, each value an array of response times in milliseconds, oldest first. Open in every edition.
MCP and OAuth routes¶
The MCP server is off unless MAINTENANT_MCP is enabled, and none of these routes exist in demo mode. When MAINTENANT_MCP_CLIENT_ID and MAINTENANT_MCP_CLIENT_SECRET are set, /mcp requires an OAuth access token and the /.well-known/ and /oauth/ routes exist. Without them the instance refuses to start, unless MAINTENANT_MCP_ALLOW_UNAUTHENTICATED leaves /mcp open, in which case only /mcp is served. See MCP Server.
| Method | Endpoint | Description |
|---|---|---|
GET, POST, DELETE |
/mcp, /mcp/ |
MCP over streamable HTTP. Needs Authorization: Bearer <token> when OAuth is configured |
GET |
/.well-known/oauth-authorization-server |
Authorization server metadata |
GET |
/.well-known/oauth-protected-resource |
Protected resource metadata for /mcp |
GET |
/oauth/authorize |
Authorization endpoint (authorization code with PKCE) |
POST |
/oauth/token |
Token endpoint |
/oauth/authorizetakesresponse_type=code,client_id,redirect_uri,code_challenge,code_challenge_method=S256and an optionalstate. It asks for no login: a valid request is approved at once and redirected with acode. The redirect URI must be a loopback address or one ofMAINTENANT_MCP_ALLOWED_REDIRECT_URIS. The code lives 10 minutes and works once./oauth/tokentakes form fields.grant_type=authorization_codeneedscode,client_id,client_secret,redirect_uriandcode_verifier;grant_type=refresh_tokenneedsrefresh_token,client_idandclient_secret. The client authenticates with its secret in the form (client_secret_post). The answer is{ "access_token", "token_type": "Bearer", "expires_in": 3600, "refresh_token" }: access tokens last 1 hour, refresh tokens 30 days, and each refresh token is used once.- OAuth errors use the RFC 6749 shape:
invalid_request,invalid_client(401),invalid_grant,unsupported_grant_type,unsupported_response_type,unauthorized_clientandserver_error./mcpanswers401with aWWW-Authenticateheader pointing to the protected resource metadata when the token is missing, expired or revoked. - The issuer of the metadata is
MAINTENANT_BASE_URL.
SSE Event Stream¶
Connect to the real-time event stream:
This is a Server-Sent Events (SSE) endpoint. The server writes each event as:
The SSE event: field is the event type, and data: holds the JSON payload only (there is no {type, data} envelope). There is no id: field and no event on connection: a client only receives events broadcast after it connects, and refetches state after a reconnect. While the stream is silent, the server sends the comment : keepalive every 25 seconds so that a proxy does not cut it; clients ignore comments. The status.* events of the status page are sent on this stream as well. A client that falls 64 events behind loses events instead of slowing the others. The response sets Cache-Control: no-cache and X-Accel-Buffering: no.
Event Types¶
| Event | Emitted when | Payload keys |
|---|---|---|
container.discovered |
A container the store did not know appears (not for ignored containers) | the container object |
container.state_changed |
A container changes state | id, state, previous_state, health_status, exit_code, timestamp, agent_id |
container.health_changed |
A container's health status changes | id, container_name, health_status, previous_health, timestamp, agent_id |
container.archived |
A container is destroyed, vanished while offline, missing from an agent inventory or deleted through the API | id, archived_at, agent_id (the API delete sends id only) |
container.restart_alert |
A restart loop is detected | container_id, container_name, restart_count, threshold, severity, timestamp, agent_id |
container.restart_recovery |
The restart rate is back under the threshold | container_id, container_name, timestamp, agent_id |
endpoint.discovered |
An endpoint is discovered from labels or created through the API | endpoint_id, container_name, endpoint_type, target, plus agent_id for an agent's endpoint and source (standalone) and name for a standalone one |
endpoint.status_changed |
An endpoint changes status after a check, or becomes unknown because its container stopped |
endpoint_id, container_name, target, previous_status, new_status, error, timestamp, plus response_time_ms, http_status and agent_id after a check |
endpoint.removed |
An endpoint is deactivated or deleted | endpoint_id, reason (label_removed, container_destroyed, container_gone or user_deleted), plus container_name and agent_id when they apply |
endpoint.alert |
An endpoint reaches its failure threshold | endpoint_id, container_name, target, consecutive_failures, threshold, last_error, timestamp |
endpoint.recovery |
An alerting endpoint reaches its recovery threshold | endpoint_id, container_name, target, consecutive_successes, threshold, timestamp |
endpoint.config_error |
An endpoint label cannot be parsed | endpoint_id (null), container_name, label_key, error, timestamp, plus agent_id for an agent's container |
heartbeat.created |
A heartbeat monitor is created | heartbeat_id, name, status |
heartbeat.updated |
A heartbeat monitor is edited | heartbeat_id, name, interval_seconds, grace_seconds, plus next_deadline_at when the heartbeat has a deadline |
heartbeat.ping_received |
A ping arrives (ping_type: success, start or exit_code) |
heartbeat_id, ping_type, status, plus exit_code or agent_id |
heartbeat.status_changed |
A heartbeat changes status (ping, missed deadline, pause, resume) | heartbeat_id, old_status, new_status, agent_id on a ping |
heartbeat.alert |
A deadline is missed or a ping carries a non-zero exit code | heartbeat_id, name, alert_type, details |
heartbeat.recovery |
A failing heartbeat recovers | heartbeat_id, name, agent_id on a ping |
heartbeat.deleted |
A heartbeat monitor is deleted | heartbeat_id |
certificate.created |
A certificate monitor is created or auto-detected | monitor_id, hostname, port, source, plus server_name and agent_id when they apply |
certificate.check_completed |
A check finishes, including failed ones | monitor_id, hostname, status, checked_at, plus subject_cn, issuer_cn, not_after, days_remaining, chain_valid, hostname_match when known |
certificate.status_changed |
A monitor changes status | monitor_id, hostname, previous_status, new_status, days_remaining, timestamp |
certificate.alert |
Expiry threshold, invalid chain, hostname mismatch, revoked OCSP response or expiry | monitor_id, hostname, port, alert_type, severity, timestamp, agent_id and details |
certificate.recovery |
A certificate alert clears: the certificate was renewed, or the chain, hostname or OCSP problem is gone | monitor_id, hostname, port, previous_alert_type (expiring, expired, chain_invalid, hostname_mismatch or ocsp_revoked), days_remaining, timestamp, agent_id, plus new_not_after and server_name when they apply |
certificate.deleted |
A certificate monitor is deleted | monitor_id, hostname |
resource.snapshot |
A sample is stored (live samples only) | container_id, cpu_percent, mem_used, mem_limit, mem_percent, net_rx_bytes, net_tx_bytes, block_read_bytes, block_write_bytes, timestamp, agent_id |
resource.alert |
CPU or memory stays over its threshold (one event per metric) | container_id, container_name, alert_type (cpu or memory), current_value, threshold, timestamp, agent_id |
resource.recovery |
CPU or memory returns to normal (one event per metric) | container_id, container_name, recovered_type (cpu or memory), current_value, threshold, timestamp, agent_id |
alert.fired |
An alert is raised, or its severity escalates | the alert: id, source, alert_type, severity, status, message, entity_type, entity_id, entity_name, agent_id, details (an object), fired_at, created_at |
alert.silenced |
An alert is raised while a silence rule or a maintenance window matches | same as alert.fired |
alert.resolved |
An alert resolves | same as alert.fired, with resolved_at |
alert.acknowledged |
An alert is acknowledged, from the REST route, the MCP server or a security posture acknowledgment | the alert as stored: details is a string holding JSON |
channel.created, channel.updated |
A channel changes (REST or MCP) | the channel object |
channel.deleted |
A channel is deleted | id |
trigger.created, trigger.updated |
A trigger changes | the trigger object |
trigger.deleted |
A trigger is deleted | id |
silence.created |
A silence rule is created | the rule object |
silence.cancelled |
A silence rule is cancelled | id |
runtime.context_changed |
Swarm is activated or deactivated (deactivating Swarm also stops the Swarm manager loops) | previous, current, message, detected_at |
runtime.availability_changed |
The runtime connects or is lost (the Docker event stream closing once the daemon stops answering counts as a loss), and once when the server starts | name, connected |
storage.availability_changed |
The database becomes unreachable, or answers again | engine, connected |
update.scan_started |
A scan starts | scan_id, started_at |
update.scan_completed |
A scan ends | scan_id, updates_found, errors |
update.detected |
A scan finds an update (on every scan, for each container) | container_id, container_uid, container_name, image, current_tag, latest_tag, update_type, risk_score, alert_on, agent_id, plus update_command, rollback_command |
update.resolved |
An update is no longer pending | container_id, container_uid, container_name, agent_id (empty when the scan no longer sees the container) |
security.insights_changed |
A container's insights change | container_id, container_name, highest_severity, count, change |
security.insights_resolved |
All insights of a container are gone | container_id, container_name |
security.posture_changed |
The posture score moved by 5 points or more, or changed colour (needs MAINTENANT_SECURITY_SCORE_THRESHOLD; evaluated at every scoring) |
score, previous_score, color |
swarm.service_discovered |
A Swarm service is created | service_id, name, mode, desired_replicas, stack_name, image |
swarm.service_updated |
A Swarm service is updated, or stays under-replicated for 5 minutes | service_id, name, desired_replicas, running_replicas, plus image for an update and replica_alert for under-replication |
swarm.service_removed |
A Swarm service is removed | service_id, name |
swarm.status |
The Swarm state this node manages changes: Swarm is activated (active: true) or deactivated (active: false, no other key), or the manager or worker counts move. It is not sent at startup, before any client listens |
active, plus is_manager, cluster_id, manager_count, worker_count when active |
swarm.node_status_changed |
A node's status or availability changes | node_id, hostname, role, old_status, new_status, old_availability, new_availability |
swarm.node_updated |
A node joins, or its role, host name, engine version or address changes | node_id, hostname, role, status, availability, engine_version, address, task_count |
swarm.task_failed |
Swarm marks a task failed, on any node, with an exit code other than 0 and 143 (137 counts). Tasks stopped by a rolling update or a scale-down do not count | task_id, service_id, service_name, node_id, container_id, error, exit_code, timestamp |
swarm.crash_loop_detected |
A service has 3 failed tasks within 5 minutes | service_id, service_name, failure_count, window_minutes, last_error, timestamp |
swarm.crash_loop_recovered |
A crash-looping service is stable for 10 minutes | service_id, service_name, timestamp |
swarm.update_progress |
A rolling update progresses | service_id, service_name, state, tasks_updated, tasks_total, new_image, message, timestamp |
swarm.update_completed |
A rolling update completes or rolls back | service_id, service_name, state, message, started_at, completed_at |
swarm.topology_changed |
A remote agent reported a new Swarm topology | agent_id |
kubernetes.topology_changed |
A remote agent reported a new Kubernetes topology | agent_id |
kubernetes.workload_changed |
An alert of a workload of the server's own cluster is raised or resolved | id, namespace, name |
kubernetes.pod_changed |
An alert of a pod of the server's own cluster is raised or resolved | namespace, name |
kubernetes.node_changed |
An alert of a node of the server's own cluster is raised or resolved | name |
agent.created |
An agent enrols | agent_id, hostname, label, runtime, status |
agent.updated |
An agent's label or host OS changes | agent_id, label for a label change, the agent object for a host OS change |
agent.revoked, agent.deleted |
An agent is revoked or deleted | agent_id |
agent.connected, agent.disconnected |
An agent stream opens or closes (or goes stale) | agent_id |
Events carrying agent_id use the sentinel 00000000-0000-0000-0000-000000000000 for the local runtime. Data that a remote agent replays from its spool after a reconnection is stored for history and does not raise events.
Events the server defines but does not send
runtime.status, update.pinned and update.unpinned are declared but never emitted.
Container log stream¶
GET /api/v1/containers/{id}/logs/stream is a separate stream per container (see Containers). It sends container.log_line and, at the end, container.log_error.
Status page stream¶
The public status page has its own event stream, separate from the one above:
It carries only the status page events, so the public page never receives dashboard data. The dashboard stream above receives the same events without the visibility filter: it names every component.
| Event | Emitted when | Payload keys |
|---|---|---|
status.component_changed |
A monitor linked to a component changes state. Sent to the public stream only for a visible component | component_id, name, status, monitors |
status.component_created, status.component_updated, status.component_deleted |
An administrator creates, edits or deletes a component | component_id only. Sent to the public stream only when the component is visible, or was before the change |
status.global_changed |
Right after every status.component_changed, status.component_created, status.component_updated and status.component_deleted |
status, message |
status.incident_created |
An incident is created, manually or automatically | id, title, severity, status, components (visible components only on the public stream) |
status.incident_updated |
An update is added to an incident | id, status, message |
status.incident_resolved |
An incident is resolved | id, title |
status.maintenance_started |
A maintenance window starts | id, title, components (visible components only on the public stream) |
status.maintenance_ended |
A maintenance window ends | id, title, components (visible components only on the public stream) |