Gareth Wilson Gareth Wilson

Guide to Tailscale Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAdmin console, Settings > Webhooks, by an Owner, Admin, Network admin or IT admin
URL requirementsHTTPS, on port 80 or 443
SubscriptionEvent categories (including future events in them) or individual events
BodyA JSON array of events, possibly of mixed types
Signature headerTailscale-Webhook-Signature: t=<timestamp>,v1=<signature>
SchemeHMAC-SHA256, hex digest. v1 is the only scheme
Signed content<t>.<body>
SecretUnique per endpoint, case-sensitive, shown once, no expiry, rotatable
TestingTest endpoint > Send test event sends an event of type test
RetriesHourly for up to 24 hours on 3xx, 4xx, 5xx or no response
Source IPsNot published

Common events

CategoryEventsFires when
Tailnet managementnodeCreated, nodeDeletedA node is created or deleted, including automatic deletion of ephemeral nodes
Tailnet managementnodeNeedsApproval, nodeApprovedA node needs device approval, or was approved. Pre-approved nodes do not send nodeApproved
Tailnet managementnodeKeyExpiringInOneDay, nodeKeyExpiredA node key will expire in less than a day, or recently expired
Tailnet managementnodeNeedsSignature, nodeSignedA node needs, or received, a signature from a trusted node (Tailnet Lock only)
Tailnet managementpolicyUpdateThe tailnet policy file was updated
Tailnet managementuserCreated, userNeedsApproval, userApproved, userRoleUpdatedA user is created, needs approval, is approved, or has their role changed
Device misconfigurationexitNodeIPForwardingNotEnabled, subnetIPForwardingNotEnabledAn exit node or subnet router has IP forwarding disabled
Webhook managementtest, webhookUpdated, webhookDeletedA 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

  1. Open the Webhooks page of the admin console and select Add endpoint.
  2. Enter your Webhook URL. It must be HTTPS on port 80 or 443.
  3. Leave Destination as None to get Tailscale's own JSON format.
  4. Select event categories or individual events, then Add endpoint.
  5. 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.
  6. From the endpoint's menu, choose Test endpoint > Send test event, and check that an event of type test arrives.

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 nodeNeedsApproval and userNeedsApproval, 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 200 for every verified request, even if you can't process one of the events in it. Log and handle the failure yourself.
  • Reserve 5xx for transient problems, such as a database outage.
  • Don't redirect the webhook URL, since a 3xx counts 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.