📚 API Documentation¶
Base URL: /api (REST endpoints use /api/v1 prefix unless otherwise noted)
Note: All API responses are JSON formatted. All protected endpoints require a valid session: the
kasm_sessioncookie set by the login, or the same JWT in anAuthorization: Bearer <token>header.Validation: Every endpoint that takes a body or query parameters checks them against a Zod schema from
@kasm/sharedbefore doing anything else. A request that does not match is answered with400and a single message that starts with the path of the first offending field, e.g.{ "error": "inboundAllowedIp: Must be an IPv4 address or an IPv4 network in CIDR notation" }. Only the first problem is reported; fix it and the next request names the next one.
📖 Table of Contents¶
- Authentication
- Users
- Clients
- Registration Tokens
- keepalived
- Settings & Maintenance
- Activity
- Webhooks
- Misc
- WebSockets
🔐 Authentication¶
Revocation. A valid signature is not enough. The JWT carries the user's
token_version as tv, and every protected request and every dashboard handshake
compares it with the user's row. Changing a user's password or auth methods raises that
counter, and deleting the user removes the row, so all of that user's sessions end at
once: the next request answers 401, an open dashboard socket is closed with 4001.
A user who changes their own password gets a fresh cookie in the same response; their
dashboard socket closes with 4002 and reconnects on it. Tokens issued before this
existed carry no tv and are refused — one login after the update.
Login¶
POST /api/login
Description: Authenticates a user with local credentials and starts a session. The session token is set as a cookie and is not part of the response body.
Request Body¶
| Field | Type | Required | Description |
|---|---|---|---|
username |
string | Yes | The username of the user. |
password |
string | Yes | The password of the user. |
Example Request:
Response¶
The response sets two cookies, both with Path=/, SameSite=Strict and a Max-Age that ends when the token does (jwtExpiresIn, default 12h). Secure is added when the request came in over HTTPS (behind a reverse proxy listed in security.trusted_proxies: X-Forwarded-Proto: https).
| Cookie | HttpOnly |
Content |
|---|---|---|
kasm_session |
yes | The JWT. Sent by the browser on every request and on the WebSocket handshake. |
kasm_auth |
no | 1. Carries no secret; tells the dashboard that a session exists. |
Every protected endpoint accepts the session either as the kasm_session cookie or as Authorization: Bearer <token>. A script can log in with this endpoint and send the value of kasm_session as a bearer token.
- 400 —
usernameorpasswordmissing or empty. A malformed request is not a failed login. - 401 —
Invalid credentials: unknown user, wrong password, or an account without a local password (OIDC only). All three answer the same.
Rate Limit¶
At most 10 attempts per 15 minutes per client IP, successful or not. Further attempts
are answered with 429 Too Many Requests until the window has passed; the response carries
x-ratelimit-* and retry-after headers. No other endpoint is rate limited.
Logout¶
POST /api/auth/logout
Description: Ends the browser session by clearing both cookies. Unauthenticated, so that an expired session can be logged out of too. The token itself stays valid until it expires; the server keeps no session list to revoke it from.
Response¶
Current Session¶
GET /api/v1/me
Description: Who the current session belongs to and when it expires. The dashboard reads the username, the user id and the expiry (for its automatic logout) from here, because it cannot read the httpOnly cookie. Protected like every /api/v1 endpoint: without a valid session it answers 401.
Response¶
| Field | Type | Description |
|---|---|---|
id |
number | The user's id. |
username |
string | The user's name. |
expiresAt |
string | null | ISO 8601 time at which the session expires. |
OIDC Configuration¶
GET /api/auth/config
Description: Returns the public authentication configuration. Used by the frontend to determine whether to show local login, OIDC login, or both.
Response¶
| Field | Type | Description |
|---|---|---|
type |
string | "oidc" while an OIDC provider is configured and was discovered at startup, otherwise "local". Local login stays available either way. |
OIDC Login¶
GET /api/auth/login
Description: Redirects the user's browser to the OIDC provider's login page. Generates a PKCE code verifier/challenge and stores state for CSRF protection.
Response¶
- 302 Redirect: Redirects to the OIDC provider.
- 404 — OIDC is not configured.
OIDC Callback¶
GET /api/auth/callback
Description: Handles the callback from the OIDC provider. Exchanges the authorization code for a local JWT session token.
Query Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
code |
string | Yes | The authorization code returned by the OIDC provider. |
state |
string | Yes | The state parameter for CSRF protection. |
Response¶
- 302 Redirect: Sets the session cookies (see Login) and redirects to
/. The token is not put into the redirect URL. - 500 —
Authentication failed: <reason>: OIDC is disabled, the state does not match, or the code exchange failed.
👤 Users¶
List Users¶
GET /api/v1/users
Description: Retrieves a list of all registered users.
Response (Array of User objects)¶
| Field | Type | Description |
|---|---|---|
id |
number | The unique identifier of the user. |
username |
string | The username. |
auth_methods |
string | Comma-separated list of allowed authentication methods. |
created_at |
string | ISO 8601 timestamp of creation. |
updated_at |
string | ISO 8601 timestamp of last update. |
Example Response:
[
{
"id": 1,
"username": "admin",
"auth_methods": "local",
"created_at": "2024-01-01T10:00:00.000Z",
"updated_at": "2024-01-01T10:00:00.000Z"
}
]
Create User¶
POST /api/v1/users
Description: Creates a new user.
Request Body¶
| Field | Type | Required | Description |
|---|---|---|---|
username |
string | Yes | The desired username. |
password |
string | Conditional | Required when auth_methods includes "local". |
auth_methods |
string | No | Auth methods: "local", "oidc", or "local,oidc". Defaults to "local". Any other value is rejected with 400. |
Example Request:
Response¶
- 400 — invalid body, or
auth_methodsincludeslocalwithout apassword. - 409 — username already exists.
Update User¶
PUT /api/v1/users/:userId
Description: Updates an existing user's password or authentication methods.
Path Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
userId |
string | Yes | The ID of the user to update. |
Request Body¶
| Field | Type | Required | Description |
|---|---|---|---|
password |
string | No | The new password. Only valid if user has "local" auth method. |
auth_methods |
string | No | New comma-separated list of local and oidc. An empty string leaves the methods unchanged. |
Response¶
- 400 — invalid body, a
passwordfor a user withoutlocal, orlocalfor a user who has no password and gets none. - 404 — user not found.
Delete User¶
DELETE /api/v1/users/:userId
Description: Deletes a user. Cannot delete yourself or the last remaining user.
Path Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
userId |
string | Yes | The ID of the user to delete. |
Response¶
- 400 —
Cannot delete yourselforCannot delete the last user. - 404 — user not found.
🖥️ Clients¶
List Clients¶
GET /api/v1/clients
Description: Retrieves a list of all registered clients enriched with their live connection status from ProxyService.
Response (Array of Client objects)¶
| Field | Type | Description |
|---|---|---|
id |
string | Client UUID. |
hostname |
string | Hostname of the client machine. Always a string: a row without one is sent as "". |
displayName |
string | null | Optional human-readable name. |
site |
string | null | Network segment or location the operator put the client in. Part of the cluster key; null for none. |
status |
string | "online" or "offline". |
lastSeen |
string | null | ISO 8601 timestamp of last connection. |
version |
string | null | Agent version reported on last connection. |
connectionMode |
string | "inbound" (agent dials in) or "outbound" (server dials the agent). |
inboundAllowedIp |
string | null | Inbound clients: the address or IPv4 network connections must come from; null when the check is switched off. |
inboundLastIp |
string | null | Inbound clients: the address the agent last authenticated from. Read-only and written only after the check above has passed, so it is always an address that was let in. The client editor measures a new inboundAllowedIp against it and warns before a value is saved that would refuse the agent. null until the agent has connected once. |
outboundTargetAddress |
string | null | Outbound clients: host:port the server dials. |
capabilities |
string[] | null | What the agent currently connected says it can do, as it named it in its AUTH payload (see below). null while the client is offline — capabilities belong to the build on the wire, not to the stored client; [] is a connected agent that names none. Current agents name vrrp. |
createdAt |
string | When the client was registered. |
updatedAt |
string | null | When the stored record last changed. |
Example Response:
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"hostname": "lb-01",
"displayName": "Load balancer 1",
"site": null,
"status": "online",
"lastSeen": "2024-01-01T12:30:00.000Z",
"version": "1.0.0",
"connectionMode": "inbound",
"inboundAllowedIp": "192.168.1.50",
"inboundLastIp": "192.168.1.50",
"outboundTargetAddress": null,
"capabilities": ["vrrp"],
"createdAt": "2024-01-01 10:00:00",
"updatedAt": "2024-01-01 12:30:00"
}
]
Create Outbound Client¶
POST /api/v1/clients/outbound
Description: Adds a client that the server connects to (outbound mode), instead of the agent dialling in. The server opens <scheme>://<outboundTargetAddress>/ws/register — wss:// when the stored address carries that prefix, ws:// otherwise — hands over the agent's setup PIN (or its KASM_REGISTRATION_SECRET) together with a newly generated auth token and the client's server-issued clientId (stored by the agent in identity.json in its data directory), and then opens the regular agent session on /ws/agent, presenting both halves of that identity in the query string — the agent refuses a caller that does not name the id it was registered under. The client is written to the database only after that session has authenticated.
Request Body¶
| Field | Type | Required | Description |
|---|---|---|---|
outboundTargetAddress |
string | Yes | host, host:port or wss://host:port of the agent's web server. Without a port, :3011 is appended. wss:// dials the agent over TLS, which requires the agent to serve it (see client.md); a bare address, or one written ws://, is stored and dialled as plaintext. Any other scheme, and a path, query or credentials, are refused — the value is interpolated into a WebSocket URL. |
registrationSecret |
string | Yes | The setup PIN from the agent's log, or the value of KASM_REGISTRATION_SECRET if the agent has one. The agent tells the two apart. |
hostname |
string | No | Name shown for the client. Defaults to outboundTargetAddress. |
Response¶
An empty outboundTargetAddress or registrationSecret is answered with 400 before any connection is attempted. On a failed handshake the endpoint answers 503 with { "error": "Could not establish connection to client. <reason>" }. The reason is derived from how the agent ended the handshake, and the request returns as soon as the agent closes the connection:
| Agent response | Reason given |
|---|---|
Close 4003 Already registered |
The agent already holds an identity; delete its identity.json, restart it and use the new setup PIN. |
Close 4003 No registration secret configured |
Only from agents older than the setup PIN: they need registrationSecret in their config.yaml. |
REGISTRATION_FAILURE / close 4003 Invalid secret |
Neither the setup PIN nor the secret matches. After 5 wrong attempts the agent logs a new PIN. |
Close 4001 Registration timed out |
The agent gave up waiting for the registration request. |
| Connection error / no answer within 10 s | The underlying error, or a timeout message. |
| Registration succeeded, AUTH failed | The agent has already stored its token; it must be reset before retrying. |
Update Client¶
PUT /api/v1/clients/:clientId
Description: Updates a client's display name, for inbound clients the address its connections must come from, and for outbound clients the address the server dials. At least one field is required.
Path Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
clientId |
string | Yes | The UUID of the client. |
Request Body¶
| Field | Type | Required | Description |
|---|---|---|---|
displayName |
string | No | The new display name for the client. |
site |
string | null | No | At most 100 characters, trimmed. null or an empty string clears it; leaving the field out keeps the stored value. |
inboundAllowedIp |
string | null | No | Inbound clients only. An IPv4 address or CIDR network restricts connections to it; null switches the check off; leaving the field out keeps the stored value. |
outboundTargetAddress |
string | No | Outbound clients only. host, host:port or wss://host:port the server dials; without a port, :3011 is appended. Same rule as on POST /clients/outbound. |
Response¶
A changed outboundTargetAddress takes effect immediately: the open agent socket is closed, any pending reconnect is cancelled, and the new address is dialled at once rather than at the next backoff step. The reply does not wait for that attempt — an unreachable new address answers 200 and the client goes offline until a reconnect succeeds. Stored addresses are validated only when written, so a value saved before this check existed keeps working until it is edited.
- 400 — no field given,
inboundAllowedIpnot an IPv4 address or network,inboundAllowedIpsent for an outbound client,outboundTargetAddresssent for an inbound client, oroutboundTargetAddressnot a usablehost:port. - 404 — client not found.
Delete Client¶
DELETE /api/v1/clients/:clientId
Description: Removes a client registration. If the client is currently connected, its WebSocket connection is terminated.
Path Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
clientId |
string | Yes | The UUID of the client to delete. |
Response¶
The client's last keepalived reading goes with it. For an outbound client any pending reconnect is cancelled first; an open agent socket is closed with 4000 Client deleted. Nothing on the host is touched.
- 404 — client not found.
Reconnect An Outbound Client¶
POST /api/v1/clients/:clientId/reconnect
Description: Dials an outbound client's agent again, now. Any open socket is closed and a pending backoff step is dropped, so the attempt starts from scratch rather than waiting out the ladder. The reply does not wait for the attempt: an unreachable agent answers 200 and the client stays offline until a later attempt succeeds. Inbound clients own their own reconnect ladder, so the route refuses them.
Path Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
clientId |
string | Yes | The UUID of the client. |
Response¶
- 400 — the client is not an outbound client.
- 404 — no such client.
🎫 Registration Tokens¶
List Tokens¶
GET /api/v1/tokens
Description: Lists all registration tokens including used and expired ones.
Response (Array of Token objects)¶
| Field | Type | Description |
|---|---|---|
tokenHash |
string | SHA-256 of the token, hex. The token itself is not stored and cannot be listed. |
createdAt |
string | Creation timestamp. |
expiresAt |
string | ISO 8601 expiry timestamp (30 min from creation). |
usedAt |
string | null | When a client registered with this token. |
displayName |
string | null | Name the client will be created under, if the token carries one. |
inboundAllowedIp |
string | null | Allowed address or network the client will start with, if the token carries one. |
The rows also carry the database columns under their own names (token_hash, created_at, expires_at, used_at, display_name, allowed_ip); the dashboard reads the camelCase fields.
Example Response:
[
{
"tokenHash": "9f86d081884c...",
"createdAt": "2024-01-01 10:00:00",
"expiresAt": "2024-01-01T10:30:00.000Z",
"usedAt": null,
"displayName": "lb-02",
"inboundAllowedIp": "192.168.1.0/24"
}
]
Create Token¶
POST /api/v1/tokens
Description: Generates a new short-lived registration token (valid for 30 minutes), optionally carrying what the registering agent cannot tell the server about itself.
The response is the only place the token appears in the clear: the server stores just its SHA-256 hash, and List Tokens returns that hash.
Request Body¶
The body is optional; a request without one behaves as it always did.
| Field | Type | Required | Description |
|---|---|---|---|
displayName |
string | No | Name the client is created under. Without it the hostname the agent reports is used. |
inboundAllowedIp |
string | No | IPv4 address or CIDR network the client is restricted to. Without it the address the agent registers from is used. |
Response¶
{
"token": "a1b2c3d4e5...",
"expiresAt": "2024-01-01T10:30:00.000Z",
"displayName": "lb-02",
"inboundAllowedIp": "192.168.1.0/24"
}
- 400 —
displayNamelonger than 100 characters, orinboundAllowedIpnot an IPv4 address or network.
Both defaults are stored with the token and applied by POST /api/v1/register. Tokens issued before these fields existed carry neither and keep behaving as they did.
Delete Token¶
DELETE /api/v1/tokens/:tokenHash
Description: Manually invalidates and deletes a registration token.
Path Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
tokenHash |
string | Yes | The tokenHash of the token, as the list returns it. |
Response¶
A token that does not exist answers 404 with { "error": "Token not found" }.
Register Client (Public)¶
POST /api/v1/register
Description: Public endpoint used by the client agent to register itself using a valid token. Returns the client's identity: a clientId and a permanent authToken for subsequent WebSocket connections. Both are issued by the server; every successful call creates a new client.
Request Body¶
| Field | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | A valid, unused, and non-expired registration token. |
hostname |
string | No | Hostname of the client device. Stored as unknown when missing. |
A body without a token is answered with 400, before the token is looked up. An unknown, used or expired token gets 403.
A clientId in the body, as sent by older agents, is ignored. It used to be taken over and
upserted, which let anyone holding a registration token name an existing client and replace its
auth token.
Example Request:
Response¶
The returned
tokenis the permanentauthTokenandclientIdthe id the server knows the client by. The agent saves both inidentity.jsonin its data directory; thetokenis used for all future WebSocket connections.Registering an agent again creates a new client entry; the previous one stays behind offline and can be deleted in the UI.
The new client's display name and allowed address come from the token when it carries them (see
POST /api/v1/tokens). Otherwise the agent's reported hostname names it and the address it registered from becomes its allowed address, which is what a token without defaults does.
📡 keepalived¶
What the agents read out of keepalived. The readings arrive over the agent WebSocket
(KEEPALIVED_UPDATE), are stored per client and pushed to the dashboards; these endpoints
return what is stored.
List Readings¶
GET /api/v1/keepalived/states
Description: The last reading of every client that has sent one, offline clients
included — what a host last reported is still worth showing, and its client's status says
how current it is.
Response (Array of KeepalivedState objects)¶
| Field | Type | Description |
|---|---|---|
clientId |
string | The client the reading belongs to. |
receivedAt |
string | When the server received it. |
collectedAt |
string | When the agent took it, by the agent's clock. |
running |
boolean | Whether a keepalived process was found. false is a valid reading and carries no instances. |
pid |
number | null | keepalived's parent process. |
version |
string | null | keepalived's version, read from its binary. |
source |
"data" | "json" | null |
Which dump the instances come from. |
error |
string | null | Why a running keepalived could not be read, e.g. a missing capability. |
instances |
VrrpInstance[] | See below. |
syncGroups |
{ name, state, instances: string[] }[] |
VRRP sync groups. |
lastKnownInstances |
VrrpInstance[] | undefined | Set by the server, never by the agent: the instances of the last reading that had any, while keepalived is stopped or unreadable and instances is empty. Keeps the host in its clusters. Counters (stats) are left out. |
VrrpInstance:
| Field | Type | Description |
|---|---|---|
name |
string | The vrrp_instance name. |
state |
string | INIT, BACKUP, MASTER, FAULT, STOP, DELETED, or UNKNOWN for a word this build does not know. |
wantedState |
string | null | The state the configuration asks for (state MASTER\|BACKUP). |
interface |
string | null | The interface the instance runs on. |
vrid |
number | null | virtual_router_id. |
priority / effectivePriority |
number | null | Configured priority, and the priority after track scripts and interfaces. |
advertInterval |
number | null | Seconds between advertisements. |
vips |
string[] | Virtual addresses, with prefix length where keepalived gives one. |
syncGroup |
string | null | The sync group the instance belongs to. |
lastTransition |
string | null | When the instance last changed state, from keepalived. |
stats |
object | null | keepalived's counters, keyed in snake case: advertisements_received, advertisements_sent, became_master, released_master, priority_zero_received, … |
[
{
"clientId": "550e8400-e29b-41d4-a716-446655440000",
"receivedAt": "2026-09-19T10:54:26.301Z",
"collectedAt": "2026-09-19T10:54:26.280Z",
"running": true,
"pid": 812,
"version": "2.2.8",
"source": "data",
"error": null,
"instances": [
{
"name": "VI_WEB",
"state": "MASTER",
"wantedState": "MASTER",
"interface": "eth0",
"vrid": 51,
"priority": 150,
"effectivePriority": 150,
"advertInterval": 1,
"vips": ["192.168.1.100/24"],
"syncGroup": "VG_1",
"lastTransition": "2026-09-19T10:54:26.112Z",
"stats": { "advertisements_sent": 1234, "became_master": 1 }
}
],
"syncGroups": [{ "name": "VG_1", "state": "MASTER", "instances": ["VI_WEB"] }]
}
]
List Clusters¶
GET /api/v1/keepalived/clusters
Description: The instances of all clients, grouped by the virtual router they answer
for: same client site, same VRID, and a shared network. A VRID is unique per broadcast
domain only, so the network the virtual addresses sit on is what tells two otherwise equal
clusters apart — 192.168.1.100/24 belongs to 192.168.1.0/24, and an address written
without a prefix is the host route keepalived makes of it (/32), which matches only
itself. Instances whose networks overlap are one cluster, transitively; an instance that
reports no address at all is grouped by VRID and instance name. Two sites that use the same
VRID and the same private network are told apart by the site only — every member of a
cluster needs the same one, and clients without a site share the empty one. The grouping is
buildVrrpClusters from @kasm/shared, the same function the dashboard runs.
The virtual addresses are deliberately not part of the identity. They are what this
tool watches, so a cluster whose identity changed with them could never report that its
hosts disagree about them — see vip-mismatch below. vips is therefore the union of what
the members carry, and key is <site>|<vrid>|<network>.
[
{
"key": "|51|192.168.1.0/24",
"site": null,
"vrid": 51,
"networks": ["192.168.1.0/24"],
"vips": ["192.168.1.100/24"],
"health": "degraded",
"members": [
{ "clientId": "550e…", "online": true, "reporting": true, "instance": { "name": "VI_WEB", "state": "MASTER", "…": "…" } },
{ "clientId": "7c1a…", "online": true, "reporting": true, "instance": { "name": "VI_WEB", "state": "FAULT", "…": "…" } }
]
}
]
A member's online says whether its agent is connected, reporting whether keepalived on
the host reports the instance right now. A host whose keepalived is stopped or unreadable
stays in its clusters with reporting: false and the instance it last reported
(lastKnownInstances), instead of dropping out of them.
health is counted over live members only — online and reporting. Any other member's state
is its last report, not its present one:
health |
Means |
|---|---|
split-brain |
More than one live member is MASTER. |
no-master |
Live members exist, none is MASTER. |
vip-mismatch |
The members do not all carry the same virtual addresses, so a failover changes which of them are up. The one value counted over every member, offline included: it describes the configuration, not the state. An outage above keeps its place. |
degraded |
One MASTER, but a member is in FAULT, offline, has keepalived stopped or unreadable, or is the only member. |
unknown |
No member is live. |
ok |
One MASTER, every member live and none in FAULT. |
Ordered with the clusters that need attention first.
Get A Client's Reading¶
GET /api/v1/clients/:clientId/keepalived
Description: One client's last reading, as in List Readings.
- 404 — client not found, or it has not sent a reading yet.
Refresh A Client's Reading¶
POST /api/v1/clients/:clientId/keepalived/refresh
Description: Asks the agent to read keepalived now (REQUEST_STATE_UPDATE). The
reading arrives over the dashboard WebSocket like any other; the reply only says that the
agent was asked.
- 404 — client not found.
- 409 — the client is offline.
🛠️ Settings & Maintenance¶
Get Settings¶
GET /api/v1/settings/cleanup
Description: Retrieves the settings block of config.yaml, every default filled in. The known values are returned as strings; a key added to the file by hand comes back as YAML read it. The security block is not part of the response: it is configured in config.yaml only.
Response¶
The defaults:
{
"token_retention_days": "30",
"token_cleanup_interval_hours": "24",
"notification_retention_days": "90",
"notification_retention_count": "500",
"notification_cleanup_interval_hours": "24",
"activity_level_overrides": ""
}
| Setting | Description |
|---|---|
token_retention_days |
Days to retain used/expired registration tokens before the cleanup removes them. "0" removes them on the next run. |
token_cleanup_interval_hours |
Interval of the automatic token cleanup. "0" disables the scheduler. |
notification_retention_days |
Days to keep activity events, measured against occurredAt. "0" is not "forever": it falls back to 90. |
notification_retention_count |
Minimum number of the newest activity events always kept. |
notification_cleanup_interval_hours |
Interval of the automatic activity cleanup. "0" disables the scheduler. |
activity_level_overrides |
The level an event kind is recorded with: kind=level entries separated by commas, "" for none. level is trace, info, warning, error, or none to not record the kind at all. |
The notification_* names are kept from the project KASM started from, because they are stored values; the settings page calls the list "Activity History". token_retention_days replaced retention_invalid_tokens_days without taking its value over, and retention_invalid_tokens_count is gone. Left in config.yaml, the old keys are ignored and logged as unknown at startup.
Update Settings¶
PUT /api/v1/settings/cleanup
Description: Updates settings. All fields are optional; only provided fields are updated.
Request Body¶
Pass any of the setting keys to update them.
| Kind of setting | Keys | Accepted values |
|---|---|---|
| Counts, days, intervals | token_*, notification_* |
A non-negative whole number, as string or number. Stored as string. |
| Event levels | activity_level_overrides |
Empty, or kind=level entries separated by commas or line breaks. Any kind is accepted, each only once; none is refused for vrrp.incident_opened and vrrp.incident_resolved. |
Keys not listed are accepted and written as they are: the settings page sends back everything it read, including keys an operator added to config.yaml by hand, and rejecting or dropping them would delete them from the file.
Response¶
- 400 — a value does not match the table above, or the body contains
security. Network and HSTS settings are configured inconfig.yamlonly; a session token must not be enough to lock every agent out.
A changed value takes effect without a restart:
notification_*restarts theNotificationCleanupServicescheduler,token_*theTokenCleanupService.activity_level_overridesis read whenever an event is written, so it applies to the next event and leaves the stored ones as they are.
Run Invalid Token Cleanup¶
POST /api/v1/settings/cleanup/invalid-tokens
Description: Runs TokenCleanupService now, removing registration tokens that have been invalid (used or expired) for longer than token_retention_days. Recorded as a manual run of token-cleanup.
Response¶
Run Activity Cleanup¶
POST /api/v1/settings/cleanup/notifications
Description: Runs NotificationCleanupService now, applying the retention policy to the activity table. Recorded as a manual run of notification-cleanup. The path keeps the old name; what it prunes is the activity list.
Response¶
Scheduler Status¶
GET /api/v1/settings/scheduler-status
Description: Returns the current status of the schedulers the server itself runs.
Response¶
{
"schedulers": {
"notification-cleanup": {
"isRunning": false,
"nextRun": "2026-04-19T04:00:00.000Z",
"lastRun": {
"trigger": "schedule",
"status": "success",
"startedAt": "2026-04-18T04:00:00.000Z",
"finishedAt": "2026-04-18T04:00:00.000Z",
"result": { "removed": 12 },
"error": null
}
},
"token-cleanup": { "isRunning": false, "nextRun": null, "lastRun": null }
}
}
Every scheduler reports isRunning, nextRun (null while its interval is 0) and lastRun, the last run it finished (null before its first). lastRun.trigger is schedule or manual; lastRun.status is success, failed (with error) or interrupted (the server stopped during the run; no finishedAt, no result). result is { removed } for both schedulers. The state is kept in the database and survives a restart.
📣 Activity¶
Everything that happened, as its originator reported it.
An event carries no message. It carries a kind, a level, what it is about and the facts
of that kind — the old and the new VRRP state, a priority, an error — and the text is composed
out of those by activityMessage in @kasm/shared, for the dashboard and for
webhooks alike. That is what lets an agent of an older version stay useful: it
reports the same facts and how they are worded is not its business. It also means filtering
by kind and level is exact rather than a search through prose.
level is one of trace, info, warning and error, lowest first. trace marks routine
bookkeeping — an agent connecting or disconnecting — which the dashboard hides by default.
A level this build does not know is read as info.
kind is not a closed set on the wire. An agent of another version may report a kind
this server does not know; it is stored as it is, and the dashboard falls back to printing
the kind itself rather than dropping an observation nobody can make again.
Two timestamps, and the difference matters. occurredAt is the originator's clock and
orders the list; receivedAt is the server's. After an offline stretch an event from 03:00
arrives at 08:00: it belongs at 03:00 in the list, while the seen state is per event, so a
late arrival cannot slip in under entries a user has already worked through. The gap between
the two also exposes an agent whose clock is wrong.
occurredAt of a vrrp.state_changed is keepalived's own Last transition, not the time
the agent happened to read it.
| Kind | Source | Level | data |
|---|---|---|---|
vrrp.state_changed |
agent | error to FAULT, warning from MASTER, else info |
from, to, priority, effectivePriority |
vrrp.instance_added / vrrp.instance_removed |
agent | info |
state |
keepalived.started / keepalived.stopped |
agent | info / warning |
pid, version |
keepalived.unreadable |
agent | error |
error |
client.connected / client.disconnected |
server | trace |
connectionMode, version, clientName |
client.registered |
server | info |
hostname, clientName |
scheduler.failed |
server | error |
scheduler, trigger, error; no client |
vrrp.master_changed |
server | info |
cluster, previousMaster, see below |
vrrp.split_brain / vrrp.master_lost |
server | info |
cluster, previousMaster, see below |
vrrp.incident_opened / vrrp.incident_updated |
server | warning |
cluster, incident, see below |
vrrp.incident_resolved |
server | info |
cluster, incident, see below |
subject of the agent's VRRP events is { instanceName, vrid, interface }.
Cluster events. The vrrp.* kinds from the server describe a VRRP cluster as a whole,
which no single host can see — under VRRP only the master speaks. They come on top of the
hosts' own vrrp.state_changed, and answer two questions:
- Who holds the cluster — its history, all
info: vrrp.master_changed— exactly one live member (agent online, keepalived reporting) is MASTER, and it is another host than before, or the cluster had a split brain or no master.vrrp.split_brain— more than one live MASTER.vrrp.master_lost— live members, none of them MASTER, while no offline member last reported MASTER.- How it is doing — its incidents, the kinds to alert on:
vrrp.incident_opened(warning) — the incident health (below) turned fromokinto any other.vrrp.incident_updated(warning) — while the incident is open, the health turned into another, or the findings changed: a second host in FAULT leaves the healthdegraded, but it is reported. A host's last state changing while keepalived is not reporting there is not a new finding.vrrp.incident_resolved(info) — the health isokagain, or the cluster no longer exists (client deleted, site or network changed).
The events of one incident share its id as their correlationId; the activity list shows
them as one group.
To be alerted, subscribe to vrrp.incident_*; to follow failovers, to vrrp.master_changed.
The incident health is counted differently from the dashboard's health: an agent that
is offline does not count. Losing an agent says nothing about VRRP — keepalived runs on
without it — so neither the agent's absence nor its last report is a finding. Only a cluster
with no connected agent at all is one, because it can no longer be seen. The dashboard keeps
showing an offline member as degraded.
| Condition, over the members whose agent is connected | Incident health |
|---|---|
| No agent of the cluster connected | unreachable |
| More than one live MASTER | split-brain |
| No live MASTER, while an offline member last reported MASTER | no statement — nothing changes |
| No live MASTER | no-master |
| The members disagree about the addresses | vip-mismatch |
| keepalived not reporting on a host, a member in FAULT, or a cluster of one host (counted over every member) | degraded |
| none of these | ok |
One confirmation for both. What a cluster is seen as — who holds it and how it is doing —
is compared with what was last confirmed. A difference is pending first, and counts once
every live member has delivered another reading after VRRP's master down interval
(3 × advert_int + 1 s) since it was first seen. The events of that change are made at that
moment, all of them at once, roles first, and all carry the same cluster: a state each live
host has confirmed after VRRP settled — no leftover MASTER of the host that just handed over.
A change that goes away before that is not reported, so the split brain of a moment during a
failover never is. A member that stops being live owes no reading.
When the interval is over, the server asks the members that still owe a reading for one
(REQUEST_STATE_UPDATE, as Refresh does). An agent sends an
unchanged reading on its own only every 30 s, so this is what makes a confirmation take the
interval and about a second — some 5 s with keepalived's defaults — rather than up to half a
minute. The request is sent once per pending change; if it is lost, the agent's next reading
confirms just the same. A new master is therefore reported some 5 s after the failover, dated
by keepalived's Last transition.
unreachable has no agent to deliver a reading. It counts once it has lasted 60 s, long
enough for agents to reconnect after a short break in the network (their first retry comes
after 5–8 s). A cluster whose agents come back within that time reports nothing.
No statement changes nothing, and so does a cluster with no live member for its roles; an
open incident stays open. A cluster seen for the first time takes its roles as they are,
without an event, and starts its health from ok, so one that is already unhealthy opens an
incident. Right after the server starts, nothing is evaluated until every client with a
reading has reconnected, or 60 s have passed, so a cluster whose agents are merely not back
yet does not read as unreachable.
clientId and subject.instanceName name, for a role event, the new master (the first master
of a split brain, the previous master of vrrp.master_lost); for an incident, the master, else
a live member — so the event shows in the cluster's history. occurredAt is when the confirmed
state was first seen, for a new master keepalived's Last transition.
data.cluster — what was observed, the same in all six kinds:
{
"site": "dc1", "vrid": 51, "networks": ["10.0.0.0/24"], "vips": ["10.0.0.10/24"],
"health": "ok",
"master": "lb-02",
"masters": ["lb-02"],
"members": [
{ "host": "lb-02", "clientId": "…", "instanceName": "VI_1", "state": "MASTER",
"priority": 100, "effectivePriority": 100, "online": true, "reporting": true,
"readAt": "2026-09-28T10:00:05.200Z", "vips": ["10.0.0.10/24"] },
{ "host": "lb-01", "clientId": "…", "instanceName": "VI_1", "state": "BACKUP",
"priority": 90, "effectivePriority": 90, "online": true, "reporting": true,
"readAt": "2026-09-28T10:00:05.050Z", "vips": ["10.0.0.10/24"] }
]
}
health is always the dashboard's. master is null unless there is exactly one.
members[].state of a member that is offline or not reporting is its last report, and
readAt says how old that is.
Role events add data.previousMaster: the last host that was master alone before this
change, null if there was none.
Incident events add data.incident — how the cluster was judged:
{
"id": "…",
"health": "degraded",
"previousHealth": "ok",
"reasons": [
{ "type": "not-reporting", "host": "lb-01", "lastState": "MASTER",
"text": "lb-01: keepalived not reporting, last MASTER" }
],
"history": ["degraded"],
"openedAt": "2026-09-28T10:00:00.400Z",
"confirmedAt": "2026-09-28T10:00:05.300Z"
}
reasons[].type |
Further fields |
|---|---|
unreachable |
hosts — every host of the cluster, none with its agent connected |
split-brain |
hosts — the live MASTERs |
no-master |
— |
not-reporting |
host, lastState — keepalived stopped or unreadable, agent connected |
fault |
host |
vip-mismatch |
host, missing — the addresses it lacks, among the connected members |
single-member |
— |
idis thecorrelationId;healththe incident health,previousHealththe one before this event.- Every reason carries
text, the finding as a sentence.reasonsis empty onvrrp.incident_resolved. historylists every health the incident was confirmed in.vrrp.incident_updatedaddsaddedandcleared: thetextof each finding that is new, and of each that went away, since the previous event of the incident.vrrp.incident_resolvedaddsresolvedAt(whenokwas first seen),durationSecondsandresolution:recovered, withhealthok, orremoved, withhealthnull andclusteras the last confirmation saw it.
List Activity¶
GET /api/v1/activity
seen is that of the calling user; who else has seen an event is not part of the answer.
Response:
[
{
"id": "d3f1…",
"occurredAt": "2026-09-13T03:00:07.412Z",
"receivedAt": "2026-09-13T08:14:02.900Z",
"source": "agent",
"clientId": "…",
"kind": "vrrp.state_changed",
"level": "warning",
"correlationId": null,
"subject": {
"instanceName": "VI_WEB",
"vrid": 51,
"interface": "eth0"
},
"data": { "from": "MASTER", "to": "BACKUP", "priority": 100, "effectivePriority": 100 },
"seen": false
}
]
Newest first by occurredAt.
Mark Seen¶
POST /api/v1/activity/seen with { "ids": ["…"] } marks the listed events seen by the
calling user in one request (ids that are not there are skipped; an empty or missing list is a
400) and answers { "ok": true }. There is no endpoint for a single event: the dashboard
marks a whole group, or everything the filters leave, with this one.
Delete Activity¶
DELETE /api/v1/activity removes all events and answers { "ok": true }. Single events
cannot be deleted; retention and "Delete all" are the only ways an event goes.
Retention runs on its own through notification_retention_days and
notification_retention_count — the page they are set on is called "Activity History".
Over the dashboard WebSocket: a new event goes out as ACTIVITY_APPENDED with only the events
stored for the first time (a repeat from the at-least-once delivery is not sent again); marking
sends ACTIVITY_SEEN with the ids that turned seen, to the sessions of the calling user only;
"Delete all" broadcasts ACTIVITY_UPDATE with an empty list.
The same newly stored events are handed to the webhooks.
🪝 Webhooks¶
Targets outside the dashboard that events are reported to, each with a JSON body template of its own. The template syntax, the context a template can read and examples for common services are on the Webhooks page.
A webhook as the API returns it:
{
"id": "10d18932-…",
"name": "Ops channel",
"enabled": true,
"url": "https://hooks.example.com/services/…",
"method": "POST",
"headers": { "Authorization": "Bearer …" },
"bodyTemplate": "{ \"text\": \"{{event.message}}\" }",
"minLevel": "warning",
"kinds": ["vrrp.*", "keepalived.stopped"],
"timeoutMs": 10000,
"lastStatus": 200,
"lastError": null,
"lastAttemptAt": "2026-09-27T21:01:49.380Z",
"createdAt": "2026-09-27T20:58:12.004Z",
"updatedAt": null
}
| Field | Description |
|---|---|
url |
http:// or https://. May contain placeholders, inserted as text. |
method |
POST (default) or PUT. |
headers |
Sent with every delivery; values may contain placeholders. Content-Type: application/json is always set. Returned in the clear. |
bodyTemplate |
JSON with {{…}} placeholders, filters and $if/$map/$join directives (see Webhooks), stored as written, at most 64 KiB. Refused with 400 when it is not valid JSON or a placeholder or directive does not parse. |
minLevel |
The lowest level sent: trace, info, warning (default) or error. |
kinds |
Kind patterns, * as wildcard. Empty sends every kind. |
timeoutMs |
Per attempt, 1000–60000 (default 10000). |
lastStatus / lastError / lastAttemptAt |
How the last delivery ended. Read-only. |
List Webhooks¶
GET /api/v1/webhooks answers every webhook, ordered by name.
Create Webhook¶
POST /api/v1/webhooks with the fields above except id and the read-only ones. Only
name, url and bodyTemplate are required. Answers 201 with the stored webhook.
Update Webhook¶
PUT /api/v1/webhooks/:webhookId with the same body as a create; it replaces the whole
configuration. Answers the stored webhook, or 404.
Delete Webhook¶
DELETE /api/v1/webhooks/:webhookId answers { "success": true }, or 404.
Send A Test Delivery¶
POST /api/v1/webhooks/test with the same body as a create — saved or not, so the editor can
try a target before storing it. Renders the sample event for the webhook's kinds (see
Webhooks; vrrp.state_changed when none matches), sends it once
without retries, and records nothing.
{
"ok": true,
"status": 200,
"error": null,
"body": { "text": "[warning] lb-01: VRRP instance VI_1: Master → Backup" },
"response": "ok"
}
body is what was sent, response the first 500 characters of the answer. A target that
refuses is still a 200 here, with ok: false; 400 means the webhook itself is invalid.
🏓 Misc¶
Health¶
GET /api/health (no /v1 prefix)
Description: Liveness probe, used by the image's HEALTHCHECK and the CI smoke test. No authentication — a probe has no session, and the answer discloses nothing.
| Status | Body | Meaning |
|---|---|---|
200 |
{"status":"ok"} |
The process serves requests and its database is reachable |
503 |
{"status":"error"} |
The database could not be queried |
Agent connections are not consulted: one offline agent must not mark the control plane as broken. The agent's web UI has its own GET /api/health on port 3011, which reports only that the agent process answers — not whether it is connected to the server. It serves the container's HEALTHCHECK alone: it exists only in the container image and answers only loopback, everyone else gets 404.
Why under /api: the server answers every path outside /api with the dashboard's index.html and HTTP 200, so a probe on /health would report success even without the route. Under /api, an unknown path is a 404.
Reachability¶
GET /api/v1/ping
Description: Answers "is there a KASM server at this URL?". The agent's status and register pages call it against the agent's configured server; an address an operator has just typed is checked by the registration itself. It deliberately checks nothing else: a server with a broken database is still reachable, and reporting otherwise during setup would point at the wrong problem. For "can this instance serve requests", use Health.
Response¶
🔌 WebSockets¶
Dashboard Connection¶
GET /ws/dashboard
Description: WebSocket endpoint for the web dashboard to receive real-time client status updates.
Authentication¶
The kasm_session cookie, which the browser sends with the handshake by itself. A token query parameter is no longer accepted. Without the cookie the server closes with 4001 Unauthorized, with an invalid or expired token with 4001 Invalid Token.
Behavior¶
- On connect: The server immediately sends a
CLIENTS_UPDATEwith the full client list, then oneKEEPALIVED_STATE_UPDATEper client that has a stored reading, then anACTIVITY_UPDATEwith the activity list. A dashboard therefore needs no REST call to fill its first screen. - A ping/pong heartbeat runs every 30 seconds to detect dead connections.
- All broadcasts from
ProxyService(e.g., agent connects/disconnects) are forwarded to all active dashboard sessions.
The messages are a contract¶
Every message below is a member of DashboardMessageSchema in
shared/src/dashboardMessages.ts,
one discriminated union over type. The server's senders (sendToDashboard,
broadcastToDashboard, sendToUser) take that type, so a message that is not listed does not
compile. The dashboard parses each message against the schema and drops what does not match,
reporting it once per type.
The answers of the REST endpoints a browser calls are described the same way, in
shared/src/responses.ts.
They describe what is sent, not what is accepted: a response schema carries none of the input
rules, and a nullable column arrives as null, not as a missing key.
Events (Server -> Client)¶
| Event | Payload | Description |
|---|---|---|
CLIENTS_UPDATE |
Client[] |
Full list of all clients and their statuses. |
KEEPALIVED_STATE_UPDATE |
KeepalivedState |
One client's reading, as in List Readings. The dashboard recomputes the clusters from these. |
ACTIVITY_UPDATE |
ActivityRecord[] |
The whole activity list: on connect, with the seen state of the session's user, and empty after "Delete all". |
ACTIVITY_APPENDED |
ActivityRecord[] |
Events stored for the first time, to be merged into the list by id. |
ACTIVITY_SEEN |
{ ids: string[] } |
Events the session's user has just marked seen. Sent to that user's sessions only. |
SCHEDULER_STATUS_UPDATE |
{ scheduler, status } |
One scheduler's status, in the shape of Scheduler Status, whenever a run starts or ends or its timer is set. |
Agent Connection¶
GET /ws/agent
Description: WebSocket endpoint for client agents. Requires the identity issued during registration — the clientId and the authToken together.
Query Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
clientId |
string | Yes | The server-issued clientId from the client's identity.json. |
token |
string | Yes | The permanent authToken from the client's identity.json. |
A request missing either half is closed with 4001 Authentication required. The token may
also be sent as Authorization: Bearer <token>; the id has no header form.
Authentication Stages¶
- Id and token are looked up as a pair — both have to name the same row (
4003 Invalid credentialsotherwise). The id alone is no secret, and a token alone used to make a client whoever its token happened to belong to. - Client's IP is checked against
security.allowed_networks(4003 Access denied). - A token that belongs to an outbound client is refused (
4003 Access denied): those are dialled by the server and never connect here. - Client's IP is checked against the client's allowed address or network; a client whose check is switched off skips this step (
4003 IP address mismatch). - A 5-second window is given for the client to send an
AUTHhandshake message (4001 Authentication timed outotherwise). AnAUTHwhose payload does not parse is closed with4000 Invalid payload; any other first message is answered withAUTH_FAILUREand closed with4003 Forbidden.
A second connection under the same client id replaces the first, which is closed with 4000 Replaced by new connection.
Client -> Server Events¶
AUTH
Description: Initial handshake, sent immediately after connection.
Payload:
capabilities says what this agent's build can do (currently vrrp: it reads VRRP instances
and sync groups); a missing field is read as an empty list. The server reads it by asking
whether an entry is in the list, never by exhausting it, so a newer agent may name something
this server has never heard of.
KEEPALIVED_UPDATE
Description: One reading of keepalived. Sent right after AUTH_SUCCESS, whenever a reading differs from the last one sent (counters aside), at least every 30 seconds, and on REQUEST_STATE_UPDATE.
Payload: KeepalivedState without clientId and receivedAt — see List Readings. The server parses it against KeepalivedStatusSchema and discards one that does not fit; fields it does not know are kept. An unknown state word becomes UNKNOWN instead of refusing the reading.
ACTIVITY
Description: Events the agent has observed and has not had acknowledged yet. Sent as they happen while connected, and as a batch on every reconnect. A batch, not one message per event: an agent that was offline has a queue to hand over.
Payload:
{ "events": [ { "id": "…", "occurredAt": "…", "kind": "vrrp.state_changed", "level": "warning", "correlationId": null, "subject": { }, "data": { } } ] }
source and clientId are taken from the connection, not from the payload — an agent may only ever speak about itself. Delivery is at-least-once: the event keeps its id until the server acknowledges it, and the primary key makes a second copy a no-op.
The events are parsed one by one. A batch whose envelope does not parse (no events array) is dropped without an ack, so the agent keeps offering it. A single event that does not parse but carries an id is the exception: it is acknowledged without being stored and logged. Offering it again would change nothing, and because the agent hands its queue over in order, it would block every event behind it. An unparsable event without an id is simply dropped.
Server -> Client Events¶
AUTH_SUCCESS
Payload:
lastSyncTime is always null; nothing reads it.
AUTH_FAILURE
Description: Sent when the first message of an inbound connection is not AUTH, directly before the socket is closed with 4003 Forbidden. Every other refusal is a close code alone (see Authentication Stages).
Payload: {}
REQUEST_STATE_UPDATE
Description: Asks the agent to read keepalived now and send a KEEPALIVED_UPDATE, whether or not it changed.
Payload: {}
ACTIVITY_ACK
Description: The ids the server has stored. The agent drops them from its queue; ids it does not name stay and are offered again.
Payload: { "ids": ["…"] }
After a successful
AUTH/AUTH_SUCCESSexchange, the server registers the client inProxyServiceand broadcasts aCLIENTS_UPDATEto all connected dashboards.