Build a custom webhook

Last validated:

Aperture by Tailscale is 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:

EventDescription
entire_requestFires for every completed request.
tool_call_entire_requestFires 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:

TypeDescription
toolsArray of tool calls extracted from the response.
request_bodyThe original request body sent to the LLM.
user_messageThe user's message from the request.
response_bodyThe reconstructed response body JSON.
raw_responsesArray of raw SSE messages (for streaming) or single response object.
estimated_costDollar cost estimate, pricing basis, and token usage breakdown.
grantsNon-Aperture app capabilities from the user's grants.
quotasCurrent 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