Guide to Tailscale Webhooks: Features and Best Practices
Tailscale webhooks tell you what is happening on your tailnet: a new device joins or needs approval, a node key is about to expire, someone edits the tailnet policy file, or a user's role changes. Security and IT teams use them to feed device inventories, approval workflows, audit trails and chat alerts, without polling the Tailscale API.
This guide covers how Tailscale webhook endpoints are set up and signed, the batched payload format, the events you can subscribe to, how Tailscale retries, and the best practices for handling them in production.
What are Tailscale webhooks?
A Tailscale webhook endpoint belongs to a tailnet. You create it in the admin console, choose which events it receives, and Tailscale sends an HTTPS POST to it when those events happen, usually within a few seconds.
Two details shape every receiver. First, the body is always a JSON array, and one request can carry several events of different types. Second, each request is signed with an HMAC-SHA256 over a timestamp and the body, where the timestamp is when the event occurred rather than when the request was sent.
Endpoints can also be set to a chat destination (Discord, Google Chat, Mattermost or Slack), in which case Tailscale formats the body for that tool. Everything in this guide assumes the destination is None, which uses Tailscale's own format.
Tailscale webhook features
| Feature | Details |
|---|---|
| Configuration | Admin console, Settings > Webhooks, by an Owner, Admin, Network admin or IT admin |
| URL requirements | HTTPS, on port 80 or 443 |
| Subscription | Event categories (including future events in them) or individual events |
| Body | A JSON array of events, possibly of mixed types |
| Signature header | Tailscale-Webhook-Signature: t=<timestamp>,v1=<signature> |
| Scheme | HMAC-SHA256, hex digest. v1 is the only scheme |
| Signed content | <t>.<body> |
| Secret | Unique per endpoint, case-sensitive, shown once, no expiry, rotatable |
| Testing | Test endpoint > Send test event sends an event of type test |
| Retries | Hourly for up to 24 hours on 3xx, 4xx, 5xx or no response |
| Source IPs | Not published |
Common events
| Category | Events | Fires when |
|---|---|---|
| Tailnet management | nodeCreated, nodeDeleted | A node is created or deleted, including automatic deletion of ephemeral nodes |
| Tailnet management | nodeNeedsApproval, nodeApproved | A node needs device approval, or was approved. Pre-approved nodes do not send nodeApproved |
| Tailnet management | nodeKeyExpiringInOneDay, nodeKeyExpired | A node key will expire in less than a day, or recently expired |
| Tailnet management | nodeNeedsSignature, nodeSigned | A node needs, or received, a signature from a trusted node (Tailnet Lock only) |
| Tailnet management | policyUpdate | The tailnet policy file was updated |
| Tailnet management | userCreated, userNeedsApproval, userApproved, userRoleUpdated | A user is created, needs approval, is approved, or has their role changed |
| Device misconfiguration | exitNodeIPForwardingNotEnabled, subnetIPForwardingNotEnabled | An exit node or subnet router has IP forwarding disabled |
| Webhook management | test, webhookUpdated, webhookDeleted | A test event was sent, or the webhook was updated or deleted |
webhookUpdated and webhookDeleted are subscribed by default and cannot be turned off. nodeAuthorized and nodeNeedsAuthorization are deprecated in favour of nodeApproved and nodeNeedsApproval, but endpoints that have them on keep receiving them until you turn them off.
Each element of the array has the same fields: timestamp (RFC 3339), version (currently 1), type, tailnet, a human-readable message, and an optional data object. Most events include data.actor, the user or automated process responsible. Here is a request carrying two events, taken from Tailscale's example:
[
{
"timestamp": "2022-09-21T13:59:02.949217-04:00",
"version": 1,
"type": "nodeCreated",
"tailnet": "example.com",
"message": "Node alice-workstation1.yak-bebop.ts.net created",
"data": {
"nodeID": "nFJw3SRKTM59",
"deviceName": "alice-workstation1.yak-bebop.ts.net",
"managedBy": "alice@example.com",
"actor": "alice@example.com",
"url": "https://console.tailscale.com/admin/machines/100.12.345.67"
}
},
{
"timestamp": "2023-02-27T11:49:25.208092-08:00",
"version": 1,
"type": "userRoleUpdated",
"tailnet": "example.com",
"message": "User alice@example.com role changed",
"data": {
"user": "alice@example.com",
"url": "https://console.tailscale.com/admin/users?q=alice%40example.com",
"actor": "admin@example.com",
"oldRoles": ["Member"],
"newRoles": ["Member", "IT admin"]
}
}
]
data varies by type. Node events carry nodeID, deviceName and managedBy, key expiry events add expiration, and policyUpdate carries the whole old and new policy as oldPolicy and newPolicy strings. The test event has "data": null.
Setting up Tailscale webhooks
- Open the Webhooks page of the admin console and select Add endpoint.
- Enter your Webhook URL. It must be HTTPS on port
80or443. - Leave Destination as None to get Tailscale's own JSON format.
- Select event categories or individual events, then Add endpoint.
- In the Webhook secret popup, select Copy. You cannot view the secret again after closing it. Store it server-side, for example as
TAILSCALE_WEBHOOK_SECRET. - From the endpoint's menu, choose Test endpoint > Send test event, and check that an event of type
testarrives.
To subscribe an existing endpoint to more events, edit it rather than creating a new one. Webhook creation, updates and deletions are recorded in Tailscale's configuration audit log.
For local development, the Hookdeck CLI gives you an HTTPS URL on port 443 that forwards to your machine: hookdeck listen 3000 tailscale --path /webhooks/tailscale.
Securing Tailscale webhooks
Each request has a header like:
Tailscale-Webhook-Signature: t=1663781880,v1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
To verify it, split the header on , and then on = to get t and v1. Build the string <t>.<body> from the timestamp and the raw request body, compute an HMAC-SHA256 with your webhook secret as the key, hex-encode it and compare it with v1 in constant time. Tailscale's docs say to sign the "decoded" request body. Its own Go example signs the bytes read from the request without changing them, and so does the code below.
const crypto = require('crypto');
const express = require('express');
function parseSignatureHeader(header) {
const parts = {};
for (const element of (header || '').split(',')) {
const index = element.indexOf('=');
if (index > 0) parts[element.slice(0, index).trim()] = element.slice(index + 1).trim();
}
return parts;
}
function verifyTailscaleSignature(rawBody, header, secret) {
const { t: timestamp, v1: signature } = parseSignatureHeader(header);
if (!timestamp || !/^\d+$/.test(timestamp) || !signature) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
const received = Buffer.from(signature.toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/tailscale', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.TAILSCALE_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
const header = req.get('tailscale-webhook-signature');
if (!verifyTailscaleSignature(req.body, header, secret)) {
return res.sendStatus(401);
}
// Optional: compare t with the current time. Tailscale's retries keep the original t
// (the time the event occurred), so a strict 5 minute window rejects every retry.
const events = JSON.parse(req.body.toString('utf8'));
for (const event of events) {
// Route on event.type. Persist each event, then process asynchronously.
}
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_tailscale_signature(raw_body: bytes, header, secret: str) -> bool:
parts = {}
for element in (header or "").split(","):
key, sep, value = element.partition("=")
if sep:
parts[key.strip()] = value.strip()
timestamp, signature = parts.get("t"), parts.get("v1")
if not (timestamp and timestamp.isdigit() and signature):
return False
expected = hmac.new(secret.encode("utf-8"), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# events = json.loads(raw_body) once verify_tailscale_signature(...) returns True
Tailscale points out that a valid signature proves only that the sender knows the secret. If you suspect the secret has leaked, rotate it and update your receiver.
Make Tailscale webhooks production-ready. Hookdeck Event Gateway verifies
Tailscale-Webhook-Signature, durably queues every device and policy event, and lets you replay them without tripping a timestamp check.
Tailscale webhook limitations and pain points
The suggested replay window rejects retries
The Problem: A receiver that follows Tailscale's advice to reject events more than 5 minutes old drops every retry.
Why It Happens: t is the time the event occurred, not the time of the request. Tailscale retries failed deliveries hourly, so the first retry already carries a t about an hour old. The same applies to any event you replay from a log.
Workarounds:
- Skip the timestamp check, or make it much wider than 24 hours.
- Rely on the HMAC and HTTPS for authenticity, and on deduplication for replays.
How Hookdeck Can Help: Hookdeck's Tailscale source type verifies the signature over t and the body without enforcing a timestamp window, so Tailscale's own retries and Hookdeck's replays both pass verification.
Hourly retries mean hour-long gaps
The Problem: After a brief outage, an approval request or key expiry warning arrives an hour late.
Why It Happens: Tailscale retries once an hour, for up to 24 hours. There is no fast first retry, so even a few seconds of downtime at the wrong moment delays an event by an hour, and anything still failing after 24 hours is gone. Tailscale doesn't document a response timeout.
Workarounds:
- Keep the endpoint highly available, and acknowledge quickly.
- For
nodeNeedsApprovalanduserNeedsApproval, also check pending approvals through the Tailscale API on a schedule.
How Hookdeck Can Help: Hookdeck accepts the delivery straight away, so Tailscale sees a success, and then retries delivery to your service on a schedule you choose, measured in seconds or minutes rather than hours.
Errors block later events
The Problem: Returning an error for one malformed or unwanted event delays the events that follow.
Why It Happens: Tailscale retries 3xx and 4xx responses as well as 5xx. The comment in Tailscale's own Go example says a handler should always report 2xx except for transient failures, otherwise future events will be blocked by retries.
Workarounds:
- Return
200for every verified request, even if you can't process one of the events in it. Log and handle the failure yourself. - Reserve
5xxfor transient problems, such as a database outage. - Don't redirect the webhook URL, since a
3xxcounts as a failure.
How Hookdeck Can Help: Hookdeck returns success to Tailscale once the request is stored, so a bug in your handler shows up as a failed delivery in Hookdeck's Issues rather than as a queue of hourly retries at Tailscale.
Batches mix event types
The Problem: A handler written for a single event object fails, or processes only the first event.
Why It Happens: The root of every body is an array, and Tailscale batches events "to minimize overhead". One request can contain a nodeCreated, a nodeNeedsApproval and a policyUpdate together.
Workarounds:
- Always iterate the array, and handle each element on its own.
- Make one event's failure independent of the others in the same request.
How Hookdeck Can Help: A Hookdeck transformation can reshape the array before delivery, and filters can route requests by their contents, so different teams' services only see the events they care about.
Best practices
Verify against the raw body
Build <t>. plus the exact bytes received. In Express, use express.raw(); in Next.js, await request.text(); in FastAPI, await request.body(). Parse the JSON only after the signature matches.
Deduplicate per event
There is no event ID. Build a key from type, timestamp and the main identifier in data, such as nodeID or user, and store it before processing. See our guide to webhook idempotency.
Keep the destination on None
A Slack or Discord destination changes the body to that tool's format, which is fine for chat alerts but not for code that expects Tailscale's array. If you want both, create two endpoints.
Prefer categories when you want everything
Subscribing to a category also subscribes you to events Tailscale adds to it later. Handle unknown type values by logging them and returning 200.
Acknowledge fast and process asynchronously
Verify, store the events, return 200, and do the work, such as opening tickets or calling the Tailscale API, in the background. See why to process webhooks asynchronously.
Protect policy content
policyUpdate includes the full old and new policy file. Treat stored payloads as sensitive configuration.
Conclusion
Tailscale signs each request with a hex HMAC-SHA256 of <t>.<body> in Tailscale-Webhook-Signature. Verify over the raw bytes, iterate the event array, return 200 for anything you have verified, and leave the timestamp window off or very wide, because t is the event time and Tailscale retries hourly.
Hookdeck Event Gateway verifies every Tailscale signature, answers Tailscale straight away, and retries delivery to your service in minutes instead of hours.
Get started with Hookdeck for free and handle Tailscale webhooks reliably in minutes.