# 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:

```json
[
  {
    "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.

```javascript
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:

```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](/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](/docs/use-cases/receive-webhooks#replay-for-debugging-and-recovery) 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](/docs/retries) 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](/docs/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](/docs/transformations) can reshape the array before delivery, and [filters](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) verifies every Tailscale signature, answers Tailscale straight away, and retries delivery to your service in minutes instead of hours.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Tailscale webhooks reliably in minutes.