Build a custom webhook

Last validated:

Aperture by Tailscale is currently in beta.

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 (an access rule that controls who can use which models), 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": "<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": "<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 (a set of permissions such as allowed models) 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"]
            }
          ]
        }
      ]
    }
  }
]

The grant examples on this page use Aperture configuration syntax, where the dst field is not required because the destination is the Aperture device itself. If you define grants in the tailnet policy file instead, you must include a dst key specifying the Aperture device (for example, "dst": ["tag:aperture"]). Omitting dst in a tailnet policy file grant causes the grant to silently have no effect.

Select hook events

The events array specifies when Aperture calls the hook:

EventDescription
pre_requestFires before the provider call. Synchronous: Aperture waits for the hook response. The hook can allow, block, or modify the request.
entire_requestFires for every completed request.
tool_call_entire_requestFires once after the response completes if any message in the response contained tool calls.

The pre_request event is a synchronous guardrail. Unlike entire_request and tool_call_entire_request, which fire asynchronously after a request completes, pre_request blocks until the hook responds and can allow, block, or modify the request. For details, refer to guardrails.

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.

Review 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.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-6",
    "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