Webhooks
Post signed Tailscale-format events to your own receiver, or notify Slack, Teams, Discord, Telegram, ntfy or email when machines, users or policy change.
A webhook is a URL Slopscale posts to when something happens in the tailnet:
a machine joins or leaves, a user is created or changes role, the policy
changes. Each endpoint has a secret and picks the events it wants. The
delivery format is Tailscale’s, so a receiver written for Tailscale’s
webhooks, and the tailscale.com/client/tailscale/v2 webhook endpoints,
work unchanged.
Setting one up
Create the endpoint from the console’s Integrations page, with the CLI, or
through /api/v1/webhook:
$ slopscale webhooks create --url https://ops.example.com/slopscale \
--event nodeCreated --event nodeDeleted --event userCreated
The URL may not point at a loopback, link-local or unspecified address, and the server checks the address it
resolves to when it delivers; see outbound requests for
the egress switches that widen or narrow this. The response carries the secret once. Store it on the receiver; a later read
never shows it again. slopscale webhooks rotate issues a new one, and
slopscale webhooks test sends a test event so you can check the receiver
before anything real happens.
The server keeps the last 100 deliveries per endpoint, each with the event
type, the final HTTP status or error, how many attempts it took and how long.
The list shows the newest one; Deliveries in the console’s row menu,
slopscale webhooks deliveries, or GET /api/v1/webhook/{id}/deliveries
show the rest, newest first.
Events
| Type | When |
|---|---|
nodeCreated |
A machine registered. |
nodeNeedsApproval |
A machine registered while device approval is on. |
nodeApproved |
A machine was approved. |
nodeKeyExpired |
A machine’s key expired. |
nodeDeleted |
A machine was removed. |
nodeSuspended |
An administrator suspended a machine; see Device trust. |
nodeUnsuspended |
A machine’s suspension was lifted. |
policyUpdate |
The policy file was saved or reloaded, or a group, access rule or network changed. |
userCreated |
A user was created, by an operator or by a first login. |
userNeedsApproval |
A user was created while users approval is on. |
userApproved |
A user was approved. |
userRoleUpdated |
A user’s role changed. |
userDeleted |
A user was deleted. |
accessRequestCreated |
A user asked to join a group for a while; see Temporary access. |
accessRequestApproved |
An approver granted the request and the membership was added. |
accessRequestDenied |
An approver turned the request down. |
accessRequestRevoked |
An approver ended a grant before it ran out. |
sshRecordingFailed |
An SSH session could not be recorded; see SSH session recording. |
GET /api/v1/webhook/event-types and slopscale webhooks event-types list
them. The test event goes to every endpoint on request and needs no
subscription.
Delivery format
Every delivery is a JSON array of events, so a receiver must expect more than one. Each event has this shape:
[
{
"timestamp": "2026-09-07T09:12:44Z",
"version": 1,
"type": "nodeCreated",
"tailnet": "example.com",
"message": "Node laptop (alice) joined the tailnet.",
"data": {
"nodeID": "12",
"deviceName": "laptop.example.com",
"managedBy": "alice@example.com",
"url": "https://slopscale.example.com/console/machines/12",
"expiration": "2027-03-06T09:12:44Z",
"addresses": ["100.64.0.12", "fd7a:115c:a1e0::c"]
}
}
]
tailnet is the MagicDNS base domain, or the server’s host when there is
none. The data fields are named as Tailscale names them, so a receiver
written for Tailscale reads them as is. Node events carry nodeID,
deviceName (the MagicDNS name), managedBy (the owner’s email or login,
or tagged-devices), url (the console page) and, when the key expires,
expiration; addresses and tags are extra. User events carry user,
url and userID, plus displayName when set; userRoleUpdated adds
oldRoles, newRoles and actor. policyUpdate has no data.
sshRecordingFailed carries the node fields of the machine logged into
plus event (rejected, terminated or failed), srcNode, sshUser,
localUser and the recorders tried as attempts.
A failed delivery is retried three times, after 2, 10 and 30 seconds, when the receiver answered with a 5xx or 429 or did not answer at all. A 4xx is taken as the receiver’s verdict and not retried, and so is a redirect: the payload goes to the configured URL only. At most 16 deliveries are in flight at once and each waits up to 15 seconds for a response.
Verifying the signature
Each request carries a Tailscale-Webhook-Signature header:
Tailscale-Webhook-Signature: t=1757236364,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t is the Unix time the request was signed and v1 is the hex HMAC-SHA256
of <t>.<body> under the endpoint’s secret. To verify, rebuild the HMAC from
the raw request body and compare it in constant time, and reject a t more
than a few minutes away from now to stop replays. In Go,
webhook.Verify(secret, header, body, time.Now(), 5*time.Minute) from
github.com/aislopware/slopscale/hscontrol/webhook does both. In Python:
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False
digest = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(digest, v1)
Chat providers
An endpoint with a provider type gets the message alone, in the shape the service’s incoming webhook expects, instead of the signed array:
| Provider | Body |
|---|---|
slack |
{"text": "..."} |
mattermost |
{"text": "..."} |
googlechat |
{"text": "..."} |
teams |
{"text": "..."} |
discord |
{"content": "..."} |
Paste the incoming webhook URL the service gave you and pick the provider; the
signature header is still sent but those services ignore it. For Microsoft
Teams, both the legacy incoming webhook connector and a Workflows “post to a
channel when a webhook request is received” URL take the text body.
Notifications
Three more providers reach people rather than services. They have no Tailscale counterpart.
Telegram. Create a bot with BotFather, add it to the chat, and use the
Bot API’s sendMessage URL with the chat in a chat_id query parameter:
$ slopscale webhooks create --provider telegram \
--url 'https://api.telegram.org/bot<token>/sendMessage?chat_id=-1001234567890' \
--event nodeNeedsApproval --event userNeedsApproval
The server moves chat_id from the URL into the request body, where the Bot
API expects it, and posts {"chat_id": "...", "text": "..."}. A group’s chat
ID is negative; getUpdates on the bot shows it after a message in the group.
ntfy. Use the topic URL, on ntfy.sh or your own server. The message is posted as the notification with the tailnet as its title:
$ slopscale webhooks create --provider ntfy --url https://ntfy.sh/my-tailnet-ops \
--event nodeCreated --event nodeDeleted
Email. Set the mail server once in the configuration, then use mailto:
and the recipients as the URL:
notifications:
smtp:
host: smtp.example.com
port: 587
username: slopscale
password: "..."
from: "Slopscale <slopscale@example.com>"
# starttls (default), tls for an implicit-TLS port such as 465, or
# none for a relay on localhost.
encryption: starttls
$ slopscale webhooks create --provider email \
--url 'mailto:ops@example.com, security@example.com' \
--event nodeNeedsApproval --event userRoleUpdated
A recipient may be the word approvers instead of an address. It is
resolved when the message is sent, to the email addresses of every user
whose role may decide access requests, so the endpoint follows the roles
rather than a list somebody has to keep up to date. It mixes with plain
addresses, and a person listed both ways is mailed once. An approver
whose address is not one a mail server would accept is left out, because
a rejected recipient ends the whole message for everybody on it.
An endpoint that resolves to nobody records the delivery as failed rather than retrying it: there is no later attempt at which the answer would be different. A lookup that could not be made at all is retried, and nothing is sent in the meantime, so an endpoint naming both the approvers and an address does not quietly mail only the address.
$ slopscale webhooks create --provider email \
--url 'mailto:approvers' --event accessRequestCreated
Each event is one message: the subject is the event’s message with the
tailnet in front, the body repeats it with the event type, the time and the
event’s data as JSON. Creating an email endpoint is refused while
notifications.smtp.host is unset. Authentication is PLAIN over TLS, or
CRAM-MD5 when the server offers only that; a server that only speaks LOGIN
needs an unauthenticated relay in front. A mail server that rejects the
sender, a recipient or the message is not retried; a connection or
authentication failure is, like any other delivery. The delivery status for a
sent mail is sent, since there is no HTTP status.
Every kind of endpoint takes the same subscriptions and shows up in the same list with the same delivery history.
Tailscale API
The v2 endpoints mirror Tailscale’s:
GET/POST /api/v2/tailnet/-/webhooks, GET/PATCH/DELETE, /test and
/rotate on /api/v2/webhooks/{endpointId}. They require the
webhooks scope (webhooks:read to list and read), which every admin role
holds; an auditor reads.