TS
Tailscale SOPKnowledge Base
Search
← All topics

Build a custom webhook

Last validated: Apr 9, 2026

tailscale
Agent trigger phrases:

Build a custom webhook

Last validated: Apr 9, 2026

Aperture by Tailscaleis currently in alpha.

Aperture can send real-time event data to external services through webhooks to feed LLM usage data into your own audit logs, cost dashboards, security tools, or policy engines. For each request that matches a grant, Aperture POSTs a JSON payload containing the metadata and data types you select.

Prerequisites

Before you begin, ensure you have the following:

Define a hook endpoint

Open the Settings page in the Aperture dashboard and add a hooks section to your configuration. Each hook has a unique key and specifies the endpoint URL:

"hooks": {
  "my-webhook": {
    "url": "https://example.com/aperture-events",
    "apikey": "YOUR_API_KEY"
  }
}

If your endpoint uses a non-Bearer authentication scheme, set the authorization field:

"hooks": {
  "my-webhook": {
    "url": "https://example.com/aperture-events",
    "apikey": "YOUR_API_KEY",
    "authorization": "x-api-key",
    "timeout": "10s"
  }
}

The authorization field supports bearer (default), x-api-key, and x-goog-api-key. The timeout field accepts Go duration strings such as 5s, 30s, or 1m and defaults to 5s.

A hook defined in the hooks section has no effect until a grant references it.

Trigger the hook from a grant

Add a send_hooks entry to a capability in your grants section. This controls which requests trigger the hook and what data Aperture includes in the payload:

"grants": [\
  {\
    "src": ["*"],\
    "app": {\
      "tailscale.com/cap/aperture": [\
        {\
          "models": "**",\
          "send_hooks": [\
            {\
              "name": "my-webhook",\
              "events": ["entire_request"],\
              "send": ["estimated_cost"]\
            }\
          ]\
        }\
      ]\
    }\
  }\
]

Select hook events

The events array specifies when Aperture calls the hook:

| Event | Description | | --- | --- | | entire_request | Fires for every completed request. | | tool_call_entire_request | Fires once after the response completes if any message in the response contained tool calls. |

Select data types

The send array specifies which data to include in the POST payload:

| Type | Description | | --- | --- | | tools | Array of tool calls extracted from the response. | | request_body | The original request body sent to the LLM. | | user_message | The user's message from the request. | | response_body | The reconstructed response body JSON. | | raw_responses | Array of raw SSE messages (for streaming) or single response object. | | estimated_cost | Dollar cost estimate, pricing basis, and token usage breakdown. | | grants | Non-Aperture app capabilities from the user's grants. | | quotas | Current state of all quota buckets that applied to this request. |

Understand the payload format

Every hook call includes a metadata object with request context, regardless of what you specify in send:

{
  "metadata": {
    "login_name": "alice@example.com",
    "user_agent": "curl/8.0",
    "url": "/v1/chat/completions",
    "model": "gpt-5",
    "provider": "openai",
    "tailnet_name": "example.com",
    "stable_node_id": "n12345",
    "request_id": "abc123",
    "session_id": "oacc_1a2b3c4d5e6f7890"
  }
}

When you include data types in the send array, Aperture adds them to the payload alongside metadata. For example, with "send": ["tools", "estimated_cost"]:

{
  "metadata": {
    "login_name": "alice@example.com",
    "...": "...",
    "estimated_cost": {
      "dollars": 0.0342,
      "cost_basis": "anthropic/claude-sonnet-4-5",
      "usage": {
        "input_tokens": 1500,
        "output_tokens": 800,
        "cached_tokens": 200,
        "reasoning_tokens": 0
      }
    }
  },
  "tool_calls": [...]
}

Verify the webhook

After saving the configuration, send a test LLM request through Aperture and check that your endpoint receives the POST payload. If the webhook does not fire:

  1. Confirm the hook name in send_hooks matches the key in the hooks section.
  2. Confirm the grant's src and models patterns match your test request.
  3. Check the Aperture server logs for timeout or connection errors to the hook URL.

To temporarily disable a hook without removing it from the configuration, set "disabled": true in the hook definition.

Next steps

Project Logo

Ask AI

reCAPTCHA

Recaptcha requires verification.

protected by reCAPTCHA