Skip to content

📚 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_session cookie set by the login, or the same JWT in an Authorization: Bearer <token> header.

Validation: Every endpoint that takes a body or query parameters checks them against a Zod schema from @kasm/shared before doing anything else. A request that does not match is answered with 400 and 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

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:

{
    "username": "admin",
    "password": "secretpassword"
}

Response

{
    "success": true
}

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 — username or password missing 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

{
    "success": true
}

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.
{
    "id": 1,
    "username": "admin",
    "expiresAt": "2026-09-12T06:00:00.000Z"
}

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:

{
    "username": "jdoe",
    "password": "password123",
    "auth_methods": "local,oidc"
}

Response

{ "status": "created" }
  • 400 — invalid body, or auth_methods includes local without a password.
  • 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

{ "status": "updated" }
  • 400 — invalid body, a password for a user without local, or local for 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

{ "status": "deleted" }
  • 400 — Cannot delete yourself or Cannot 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

{ "id": "550e8400-e29b-41d4-a716-446655440000", "hostname": "lb-01" }

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

{ "success": true }

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, inboundAllowedIp not an IPv4 address or network, inboundAllowedIp sent for an outbound client, outboundTargetAddress sent for an inbound client, or outboundTargetAddress not a usable host: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

{ "status": "deleted" }

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

{ "status": "reconnecting" }
  • 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 — displayName longer than 100 characters, or inboundAllowedIp not 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

{ "status": "deleted" }

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:

{
    "token": "a1b2c3d4e5...",
    "hostname": "lb-02"
}

Response

{
    "token": "f8a9b2...",
    "clientId": "550e8400-e29b-41d4-a716-446655440000"
}

The returned token is the permanent authToken and clientId the id the server knows the client by. The agent saves both in identity.json in its data directory; the token is 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.

{ "status": "requested" }
  • 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.

{
    "token_retention_days": "60",
    "notification_retention_days": "30"
}
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

{ "success": true }
  • 400 — a value does not match the table above, or the body contains security. Network and HSTS settings are configured in config.yaml only; a session token must not be enough to lock every agent out.

A changed value takes effect without a restart: notification_* restarts the NotificationCleanupService scheduler, token_* the TokenCleanupService. activity_level_overrides is 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

{ "success": true, "removed": 4 }

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

{ "success": true, "removed": 12 }

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 from ok into 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 health degraded, 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 is ok again, 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 —
  • id is the correlationId; health the incident health, previousHealth the one before this event.
  • Every reason carries text, the finding as a sentence. reasons is empty on vrrp.incident_resolved.
  • history lists every health the incident was confirmed in.
  • vrrp.incident_updated adds added and cleared: the text of each finding that is new, and of each that went away, since the previous event of the incident.
  • vrrp.incident_resolved adds resolvedAt (when ok was first seen), durationSeconds and resolution: recovered, with health ok, or removed, with health null and cluster as 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

{ "status": "ok" }

🔌 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_UPDATE with the full client list, then one KEEPALIVED_STATE_UPDATE per client that has a stored reading, then an ACTIVITY_UPDATE with 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

  1. Id and token are looked up as a pair — both have to name the same row (4003 Invalid credentials otherwise). The id alone is no secret, and a token alone used to make a client whoever its token happened to belong to.
  2. Client's IP is checked against security.allowed_networks (4003 Access denied).
  3. A token that belongs to an outbound client is refused (4003 Access denied): those are dialled by the server and never connect here.
  4. 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).
  5. A 5-second window is given for the client to send an AUTH handshake message (4001 Authentication timed out otherwise). An AUTH whose payload does not parse is closed with 4000 Invalid payload; any other first message is answered with AUTH_FAILURE and closed with 4003 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:

{
    "hostname": "client-hostname",
    "version": "1.0.0",
    "capabilities": ["vrrp"]
}

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": null
}

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_SUCCESS exchange, the server registers the client in ProxyService and broadcasts a CLIENTS_UPDATE to all connected dashboards.