Agent skill

Tailscale Webhooks Skill

Receive and verify Tailscale webhooks. Use when setting up Tailscale webhook endpoints, debugging Tailscale-Webhook-Signature verification, or handling tailnet events like nodeCreated, nodeNeedsApproval, nodeKeyExpiringInOneDay, policyUpdate, and userCreated.

Install this skill

npx skills add hookdeck/webhook-skills --skill tailscale-webhooks


When to Use This Skill

  • How do I receive Tailscale webhooks?
  • How do I verify the Tailscale-Webhook-Signature header?
  • How do I handle nodeCreated, nodeNeedsApproval, or nodeKeyExpiringInOneDay events?
  • How do I react to policyUpdate or userCreated events in a tailnet?
  • Why is my Tailscale webhook signature verification failing?

Verification (core)

Tailscale sends a Tailscale-Webhook-Signature header of the form t=<unix seconds>,v1=<hex>. v1 is the hex HMAC-SHA256 of "<t>.<raw body>", keyed with the webhook secret as a raw UTF-8 string (e.g. tskey-webhook-xxxxx, case-sensitive, shown once at creation). v1 is the only scheme. Docs suggest rejecting timestamps more than 5 minutes old, but retries (Tailscale's hourly ones and Hookdeck's) keep the original t, so the window is opt-in here (TAILSCALE_TIMESTAMP_TOLERANCE_SECONDS, default 0). Always sign over the raw body.

const crypto = require('crypto');

// toleranceSeconds = 0 (default) skips the timestamp window; a positive value opts in
function verifyTailscaleWebhook(rawBody, header, secret, toleranceSeconds = 0) {
  if (!header) return false;
  const parts = header.split(',').map((p) => p.split('='));
  const t = parts.find(([k]) => k === 't')?.[1];
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || !/^\d+$/.test(t) || sigs.length === 0) return false;
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; // opt-in replay window
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  return sigs.some((sig) => {
    try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); }
    catch { return false; } // length mismatch
  });
}

Tailscale has no verification SDK (only a Go example), so verification is manual in every framework.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Common Event Types

The body is a JSON array of events (several may be batched in one request). Route on each event's type:

EventTriggered WhenCommon Use Cases
nodeCreatedA node joins the tailnetInventory, onboarding automation
nodeNeedsApprovalA node needs admin approvalOpen an approval ticket
nodeApprovedA node was approvedClose the approval ticket
nodeKeyExpiringInOneDayA node key expires in under a dayNotify the device owner
nodeDeletedA node was deleted (incl. ephemeral nodes)Remove from inventory
policyUpdateThe tailnet policy file changedAudit access-control changes
userCreatedA user was createdProvision downstream accounts
testYou clicked Send test eventConfirm the endpoint works

More types (nodeKeyExpired, userRoleUpdated, webhookUpdated, ...) are listed in references/overview.md.

Environment Variables

TAILSCALE_WEBHOOK_SECRET=tskey-webhook-xxxxx   # Shown once when the endpoint is created
TAILSCALE_TIMESTAMP_TOLERANCE_SECONDS=0       # Optional replay window; a positive value opts in

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 tailscale --path /webhooks/tailscale

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 2, 2026

View on GitHub →