Skip to content

🪝 Webhooks

KASM can report activity events to external services — a chat channel, a push service, an incident tool, a script of your own. Each webhook sends a JSON body you write yourself, with placeholders for the data of the event that triggered it.

Webhooks are managed on the Webhooks page in the sidebar, under Administration. The API is described under Webhooks in the API reference.

The webhook editor with a Gotify template and its rendered preview

The editor: target, filters and headers above, the body template beside its preview, rendered against a sample event.

When a webhook fires

Every event the server stores for the first time goes to every enabled webhook whose filters it passes:

  • Minimum level — trace, info, warning or error. The default, warning, covers a master giving up its role, a stopped keepalived and every error.
  • Event kinds — a comma-separated list of patterns, * as wildcard: vrrp.*, keepalived.stopped. Empty means every kind. The kinds are listed in the Activity reference.

An event an agent delivers twice — it reconnects before it saw the acknowledgement — is stored once and reported once.

Delivery

  • POST (or PUT) with Content-Type: application/json and the headers you configured.
  • Per attempt the configured timeout applies (default 10 s). When nothing answers, or the target answers 5xx or 429, the delivery is tried twice more, after 1 s and 5 s.
  • Events are sent to one target in the order they arrived.
  • The outcome of the last delivery is shown in the list. A failure is logged, but does not create an activity event — a broken target would otherwise report its own failures to itself.
  • There is no persistent queue. A server restart during a retry loses that delivery; the event itself stays in the activity list.

Templates

A template is valid JSON with {{…}} placeholders in its strings. It is filled in on the parsed JSON, never as text, so a quote or a brace in an event's data cannot break the body.

Written as Becomes
"{{event.data}}" — a string that is only one placeholder The value with its type: an object stays an object, a number a number, a missing value null.
"Host {{client.name}} is {{event.data.to}}" — a placeholder inside text Text. A missing value is empty, an object is written as JSON.
{{client.name \| default("server")}} The fallback when the value is missing, null or empty. The fallback is a JSON value ("text", 0, null) or text in single quotes ('text'). More filters below.
{ "{{event.kind}}": … } Placeholders work in keys too, as text.

Placeholders also work in the URL and in header values, always as text.

A path must start with event, client or webhook; anything else is refused when the webhook is saved, as is a template that is not valid JSON or longer than 64 KiB. The editor page shows a live preview rendered with a sample event, and Send Test delivers that sample to the target. The sample follows the webhook's event kinds: the first of vrrp.state_changed, vrrp.master_changed, vrrp.incident_opened, vrrp.incident_resolved and keepalived.stopped one of them matches, vrrp.state_changed when none does.

What a template can read

Placeholder Content
event.message The sentence the dashboard shows, e.g. VRRP instance VI_1: Master → Backup
event.detail The dashboard's second line, such as a priority or an error; null if none
event.kind vrrp.state_changed, keepalived.stopped, …
event.level trace, info, warning or error
event.occurredAt When it happened on the host (ISO 8601)
event.receivedAt When the server received it
event.source agent or server
event.id Unique id of the event
event.correlationId Groups events that belong together: the id of a VRRP incident; else mostly null
event.subject { instanceName, vrid, interface } for VRRP events; single fields as event.subject.vrid
event.data The facts of the kind, e.g. { from, to, priority, effectivePriority }; single fields as event.data.to
client.name Display name, else hostname
client.hostname Hostname reported by the agent
client.site The client's site
client.id Client id
webhook.name The webhook's own name

client is null for an event the server reports about itself (scheduler.failed); use default(...) where that matters. Which fields event.data carries per kind is listed in the Activity reference. An array element is reached by its position: event.data.cluster.members.0.host.

Filters

Filters follow the path, separated by |, and run left to right. They work everywhere a placeholder does, URL and headers included.

Filter Does
default(<value>) The value when the one before is missing, null or empty
join(", ") An array as text, its items separated by the given text (", " when left out). Objects in it are written as JSON.
map("host") From an array of objects, the one field of each: [{host: "lb-01"}, …] → ["lb-01", …]
upper, lower Text in upper or lower case
truncate(12) The first characters of a text, cut without an ellipsis. The number is required and at least 1.
{{event.data.cluster.vips | join(", ")}}                    → 10.0.0.10/24, 10.0.0.11/24
{{event.data.cluster.members | map("host") | join(", ")}}   → lb-01, lb-02
{{client.name | default("server") | upper}}         → LB-01
{{event.id | truncate(8)}}                          → 3f2a9c1e

A filter handed a value it cannot work on — join on a number, upper on an object — passes it on unchanged. An unknown filter is refused when the webhook is saved, as is a truncate without a whole number.

Conditions and loops

For what a single placeholder cannot say — a line only when there was a previous master, one line per host — a template uses directives: JSON objects with a key starting with $. They borrow their names from JSON-e, and like everything else they work on the parsed JSON, so they cannot break it either.

$if — takes then when the condition holds, else else:

{
    "previous": { "$if": "event.data.previousMaster", "then": "was {{event.data.previousMaster}}", "else": "first master" }
}

A branch that is left out drops what the directive stands for: the key in an object, the item in an array (null for a whole template). The condition is one of

Condition Holds when
path the value is there and not null, "", false, 0 or an empty array
!path it is not
path == 'MASTER', path != 'MASTER' the value is, or is not, equal to the one given — as a JSON value ("MASTER", 51, true, null) or text in single quotes. 51 and '51' are not equal.

There is nothing beyond these: no and, no or, no arithmetic. Two conditions are two nested $ifs.

$map — one item per element of an array, rendered with each(name), in which name is the element; each(name, index) adds its position, counted from 0:

{
    "hosts": { "$map": "event.data.cluster.members", "each(m)": { "host": "{{m.host}}", "state": "{{m.state}}" } }
}

The path names the array without braces ("{{event.data.cluster.members}}" is accepted too) and may carry filters. Anything but an array gives []. Loops nest, and an inner one can read the outer one's name. A name may not be event, client, webhook or one already in use.

$join — renders what it holds and joins the resulting array into text, separated by with (nothing when left out). That is how a loop becomes a message:

{
    "content": {
        "$join": {
            "$map": "event.data.cluster.members",
            "each(m)": {
                "$if": "m.online",
                "then": "- {{m.host}}: {{m.state}} ({{m.priority}})",
                "else": "- {{m.host}}: offline"
            }
        },
        "with": "\n"
    }
}

→ "- lb-01: MASTER (100)\n- lb-02: offline"

  • An object holding a directive may hold only that directive's own keys (then/else, each(…), with); two directives in one object are refused.
  • Directives nest at most 8 deep. A loop only ever runs over an array the event carries, so a template always finishes.
  • A key that has to reach the target starting with $if, $map or $join is written with a second $: "$$if" is sent as "$if". Other $ keys, such as $schema, are sent as written.

Examples

Slack / Mattermost incoming webhook

{
    "text": ":rotating_light: *{{client.name | default(\"KASM\")}}* — {{event.message}}"
}

Microsoft Teams (workflow webhook)

{
    "type": "message",
    "attachments": [{
        "contentType": "application/vnd.microsoft.card.adaptive",
        "content": {
            "type": "AdaptiveCard",
            "version": "1.4",
            "body": [
                { "type": "TextBlock", "weight": "Bolder", "text": "{{client.name | default(\"KASM\")}} ({{event.level}})" },
                { "type": "TextBlock", "wrap": true, "text": "{{event.message}}" }
            ]
        }
    }]
}

Gotify — URL https://gotify.example.com/message, header X-Gotify-Key: <app token>

{
    "title": "{{client.name | default(\"KASM\")}}: {{event.kind}}",
    "message": "{{event.message}}",
    "priority": 8
}

VRRP clusters report two things (see Cluster events): who holds them — vrrp.master_changed, vrrp.split_brain, vrrp.master_lost, all info, their history — and how they are doing, vrrp.incident_*, the kinds to alert on. All of them carry event.data.cluster, the cluster as every live host confirmed it; the role events add event.data.previousMaster, the incidents event.data.incident.

Failover summary — one message per change of who holds a VRRP cluster, with the new master and the state of every host. Event kinds vrrp.master_changed, vrrp.split_brain, vrrp.master_lost, minimum level info:

{
    "text": "{{event.message}}",
    "vrid": "{{event.data.cluster.vrid}}",
    "site": "{{event.data.cluster.site}}",
    "vips": "{{event.data.cluster.vips | join(\", \")}}",
    "master": "{{event.data.cluster.master}}",
    "previous": { "$if": "event.data.previousMaster", "then": "{{event.data.previousMaster}}" },
    "health": "{{event.data.cluster.health}}",
    "hosts": {
        "$map": "event.data.cluster.members",
        "each(m)": { "host": "{{m.host}}", "state": "{{m.state}}", "online": "{{m.online}}" }
    }
}

event.data.cluster.members holds host, state, priority, effectivePriority, online, reporting, readAt and vips per member; "hosts": "{{event.data.cluster.members}}" sends all of it. event.data.cluster.health is the dashboard's health. A new master is reported some 5 s after the failover, once every live host has confirmed it.

Incidents — one message when a cluster stops being healthy, one whenever its health or its findings change (event.data.incident.added / cleared name what changed), one when it is ok again. An agent going offline is no incident; all agents of a cluster offline for 60 s is one (unreachable). event.data.incident.health is the incident's own health. Event kinds vrrp.incident_*, minimum level info — the all-clear is info. event.correlationId is the same on all messages of one incident, for a target that threads them.

Log Notifier for Home Assistant — URL https://<ha>/api/lognotifier/ingest/<channel token>, event kinds vrrp.incident_*, minimum level info. Log Notifier reads KASM's levels as its own and renders content and the values in blocks as Markdown. The template raises the level to error for split-brain, no-master and unreachable, says in one sentence what happened and puts the findings, the cluster, its hosts and the course of the incident into blocks:

{
    "level": {
        "$if": "event.data.incident.health == 'split-brain'",
        "then": "error",
        "else": {
            "$if": "event.data.incident.health == 'no-master'",
            "then": "error",
            "else": {
                "$if": "event.data.incident.health == 'unreachable'",
                "then": "error",
                "else": "{{event.level}}"
            }
        }
    },
    "title": {
        "$join": [
            { "$if": "event.kind == 'vrrp.incident_opened'", "then": "🚨 " },
            { "$if": "event.kind == 'vrrp.incident_updated'", "then": "🔄 " },
            { "$if": "event.kind == 'vrrp.incident_resolved'", "then": "✅ " },
            "{{event.message}}"
        ]
    },
    "content": {
        "$if": "event.kind == 'vrrp.incident_resolved'",
        "then": {
            "$if": "event.data.incident.resolution == 'removed'",
            "then": "**Incident closed** — the cluster no longer exists (client deleted, site or network changed).",
            "else": "**Recovered** — every live member confirmed a healthy cluster."
        },
        "else": {
            "$if": "event.kind == 'vrrp.incident_updated'",
            "then": "**Incident changed** — the cluster is now **{{event.data.incident.health}}**.",
            "else": "**Incident opened** — the cluster is **{{event.data.incident.health}}**."
        }
    },
    "blocks": [
        {
            "type": "fields",
            "rows": [
                [
                    { "$if": "event.data.incident.added", "then": {
                        "label": "New findings",
                        "value": { "$join": { "$map": "event.data.incident.added", "each(t)": "- 🆕 {{t}}" }, "with": "\n" }
                    } },
                    { "$if": "event.data.incident.cleared", "then": {
                        "label": "Cleared",
                        "value": { "$join": { "$map": "event.data.incident.cleared", "each(t)": "- ✅ ~~{{t}}~~" }, "with": "\n" }
                    } }
                ],
                [
                    { "$if": "event.data.incident.reasons", "then": {
                        "label": "Open findings ({{event.data.incident.health}})",
                        "value": { "$join": { "$map": "event.data.incident.reasons", "each(r)": "- ⚠️ {{r.text}}" }, "with": "\n" }
                    } }
                ]
            ]
        },
        {
            "type": "fields",
            "rows": [
                [
                    { "label": "Site", "value": "{{event.data.cluster.site | default('–')}}" },
                    { "label": "VRID", "value": "{{event.data.cluster.vrid}}" },
                    { "label": "Master", "value": "**{{event.data.cluster.master | default('none')}}**" }
                ],
                [
                    { "label": "VIPs", "value": "{{event.data.cluster.vips | join(', ')}}" }
                ]
            ]
        },
        {
            "type": "fields",
            "rows": {
                "$map": "event.data.cluster.members",
                "each(m)": [
                    {
                        "label": "{{m.host}}",
                        "value": {
                            "$if": "m.online",
                            "then": {
                                "$if": "m.reporting",
                                "then": {
                                    "$if": "m.state == 'MASTER'",
                                    "then": "🟢 {{m.state}}",
                                    "else": { "$if": "m.state == 'FAULT'", "then": "🔴 {{m.state}}", "else": "🔵 {{m.state}}" }
                                },
                                "else": "🟠 keepalived not reporting, last {{m.state}}"
                            },
                            "else": "⚫ agent offline, last {{m.state}}"
                        }
                    },
                    {
                        "label": "Priority",
                        "value": {
                            "$if": "m.reporting",
                            "then": "{{m.effectivePriority}} / {{m.priority}}",
                            "else": "–"
                        }
                    }
                ]
            }
        },
        {
            "type": "fields",
            "rows": [
                [
                    { "label": "Opened", "value": "{{event.data.incident.openedAt}}" },
                    { "$if": "event.data.incident.durationSeconds", "then": {
                        "label": "Duration",
                        "value": "{{event.data.incident.durationSeconds}} s"
                    } },
                    { "$if": "event.kind != 'vrrp.incident_opened'", "then": {
                        "label": "History",
                        "value": {
                            "$join": [
                                "{{event.data.incident.history | join(' → ')}}",
                                { "$if": "event.data.incident.resolution == 'recovered'", "then": " → ok" }
                            ]
                        }
                    } }
                ],
                [
                    { "label": "Incident", "value": "{{event.correlationId}}" }
                ]
            ]
        }
    ],
    "source": "kasm",
    "tags": [
        "kasm",
        "{{event.kind}}",
        "{{event.data.incident.health | default('removed')}}",
        "vrid-{{event.data.cluster.vrid}}",
        "{{event.data.cluster.site | default('no-site')}}"
    ],
    "timestamp": "{{event.occurredAt}}"
}

Your own endpoint, with the complete event

{
    "source": "kasm",
    "host": "{{client.hostname}}",
    "site": "{{client.site}}",
    "event": {
        "id": "{{event.id}}",
        "kind": "{{event.kind}}",
        "level": "{{event.level}}",
        "at": "{{event.occurredAt}}",
        "subject": "{{event.subject}}",
        "data": "{{event.data}}"
    }
}

Security

  • Header values — typically a token — are stored in the clear in the server database and shown in the editor to every user who can log in. Everyone with a login can manage webhooks, just as they can manage everything else.
  • The server makes the requests, so it can reach whatever the server can reach, internal addresses included. That is intended for targets on the internal network; keep it in mind when handing out logins.
  • The log names a failing webhook and the reason, never its headers.