{"slug":"build-a-custom-webhook","title":"Build a custom webhook","tags":["tailscale"],"agent_summary":"Last validated: Apr 9, 2026","trigger_phrases":[],"runnable":false,"markdown":"\r\n# Build a custom webhook\r\n\r\nLast validated: Apr 9, 2026\r\n\r\nAperture by Tailscaleis currently [in alpha](https://tailscale.com/docs/reference/tailscale-release-stages#alpha).\r\n\r\nAperture 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.\r\n\r\n## [Prerequisites](https://tailscale.com/docs/aperture/how-to/build-custom-webhook\\#prerequisites)\r\n\r\nBefore you begin, ensure you have the following:\r\n\r\n- An [Aperture instance](https://tailscale.com/docs/aperture/get-started) with at least one provider configured.\r\n- Admin access to the [Aperture configuration](https://tailscale.com/docs/aperture/configuration).\r\n- An HTTP or HTTPS endpoint that accepts POST requests with JSON payloads.\r\n\r\n## [Define a hook endpoint](https://tailscale.com/docs/aperture/how-to/build-custom-webhook\\#define-a-hook-endpoint)\r\n\r\nOpen the **Settings** page in the [Aperture dashboard](https://tailscale.com/docs/aperture/reference/dashboard) and add a `hooks` section to your configuration. Each hook has a unique key and specifies the endpoint URL:\r\n\r\n```json\r\n\"hooks\": {\r\n  \"my-webhook\": {\r\n    \"url\": \"https://example.com/aperture-events\",\r\n    \"apikey\": \"YOUR_API_KEY\"\r\n  }\r\n}\r\n```\r\n\r\nIf your endpoint uses a non-Bearer authentication scheme, set the `authorization` field:\r\n\r\n```json\r\n\"hooks\": {\r\n  \"my-webhook\": {\r\n    \"url\": \"https://example.com/aperture-events\",\r\n    \"apikey\": \"YOUR_API_KEY\",\r\n    \"authorization\": \"x-api-key\",\r\n    \"timeout\": \"10s\"\r\n  }\r\n}\r\n```\r\n\r\nThe `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`.\r\n\r\nA hook defined in the `hooks` section has no effect until a grant references it.\r\n\r\n## [Trigger the hook from a grant](https://tailscale.com/docs/aperture/how-to/build-custom-webhook\\#trigger-the-hook-from-a-grant)\r\n\r\nAdd 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:\r\n\r\n```json\r\n\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        {\\\r\n          \"models\": \"**\",\\\r\n          \"send_hooks\": [\\\r\n            {\\\r\n              \"name\": \"my-webhook\",\\\r\n              \"events\": [\"entire_request\"],\\\r\n              \"send\": [\"estimated_cost\"]\\\r\n            }\\\r\n          ]\\\r\n        }\\\r\n      ]\\\r\n    }\\\r\n  }\\\r\n]\r\n```\r\n\r\n### [Select hook events](https://tailscale.com/docs/aperture/how-to/build-custom-webhook\\#select-hook-events)\r\n\r\nThe `events` array specifies when Aperture calls the hook:\r\n\r\n| Event | Description |\r\n| --- | --- |\r\n| `entire_request` | Fires for every completed request. |\r\n| `tool_call_entire_request` | Fires once after the response completes if any message in the response contained tool calls. |\r\n\r\n### [Select data types](https://tailscale.com/docs/aperture/how-to/build-custom-webhook\\#select-data-types)\r\n\r\nThe `send` array specifies which data to include in the POST payload:\r\n\r\n| Type | Description |\r\n| --- | --- |\r\n| `tools` | Array of tool calls extracted from the response. |\r\n| `request_body` | The original request body sent to the LLM. |\r\n| `user_message` | The user's message from the request. |\r\n| `response_body` | The reconstructed response body JSON. |\r\n| `raw_responses` | Array of raw SSE messages (for streaming) or single response object. |\r\n| `estimated_cost` | Dollar cost estimate, pricing basis, and token usage breakdown. |\r\n| `grants` | Non-Aperture app capabilities from the user's grants. |\r\n| `quotas` | Current state of all quota buckets that applied to this request. |\r\n\r\n## [Understand the payload format](https://tailscale.com/docs/aperture/how-to/build-custom-webhook\\#understand-the-payload-format)\r\n\r\nEvery hook call includes a `metadata` object with request context, regardless of what you specify in `send`:\r\n\r\n```json\r\n{\r\n  \"metadata\": {\r\n    \"login_name\": \"alice@example.com\",\r\n    \"user_agent\": \"curl/8.0\",\r\n    \"url\": \"/v1/chat/completions\",\r\n    \"model\": \"gpt-5\",\r\n    \"provider\": \"openai\",\r\n    \"tailnet_name\": \"example.com\",\r\n    \"stable_node_id\": \"n12345\",\r\n    \"request_id\": \"abc123\",\r\n    \"session_id\": \"oacc_1a2b3c4d5e6f7890\"\r\n  }\r\n}\r\n```\r\n\r\nWhen you include data types in the `send` array, Aperture adds them to the payload alongside `metadata`. For example, with `\"send\": [\"tools\", \"estimated_cost\"]`:\r\n\r\n```json\r\n{\r\n  \"metadata\": {\r\n    \"login_name\": \"alice@example.com\",\r\n    \"...\": \"...\",\r\n    \"estimated_cost\": {\r\n      \"dollars\": 0.0342,\r\n      \"cost_basis\": \"anthropic/claude-sonnet-4-5\",\r\n      \"usage\": {\r\n        \"input_tokens\": 1500,\r\n        \"output_tokens\": 800,\r\n        \"cached_tokens\": 200,\r\n        \"reasoning_tokens\": 0\r\n      }\r\n    }\r\n  },\r\n  \"tool_calls\": [...]\r\n}\r\n```\r\n\r\n## [Verify the webhook](https://tailscale.com/docs/aperture/how-to/build-custom-webhook\\#verify-the-webhook)\r\n\r\nAfter 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:\r\n\r\n1. Confirm the hook name in `send_hooks` matches the key in the `hooks` section.\r\n2. Confirm the grant's `src` and `models` patterns match your test request.\r\n3. Check the Aperture server logs for timeout or connection errors to the hook URL.\r\n\r\nTo temporarily disable a hook without removing it from the configuration, set `\"disabled\": true` in the hook definition.\r\n\r\n## [Next steps](https://tailscale.com/docs/aperture/how-to/build-custom-webhook\\#next-steps)\r\n\r\n- [Export usage data to S3](https://tailscale.com/docs/aperture/how-to/export-usage-data-to-s3) for long-term storage of session logs.\r\n- [Integrate Oso with Aperture](https://tailscale.com/docs/aperture/how-to/integrate-oso) or [Integrate Cerbos with Aperture](https://tailscale.com/docs/aperture/how-to/integrate-cerbos) for policy engine integrations that use hooks.\r\n- Refer to the [hooks configuration reference](https://tailscale.com/docs/aperture/configuration#hooks) for the complete field reference and additional examples.\r\n\r\n![Project Logo](https://cdn.brandfetch.io/tailscale.com/fallback/lettermark/theme/dark/h/256/w/256/icon?c=1bfwsmEH20zzEfSNTed)\r\n\r\nAsk AI\r\n\r\nreCAPTCHA\r\n\r\nRecaptcha requires verification.\r\n\r\nprotected by **reCAPTCHA**\r\n","html":"<h1>Build a custom webhook</h1>\n<p>Last validated: Apr 9, 2026</p>\n<p>Aperture by Tailscaleis currently <a href=\"https://tailscale.com/docs/reference/tailscale-release-stages#alpha\">in alpha</a>.</p>\n<p>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.</p>\n<h2><a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook#prerequisites\">Prerequisites</a></h2>\n<p>Before you begin, ensure you have the following:</p>\n<ul>\n<li>An <a href=\"https://tailscale.com/docs/aperture/get-started\">Aperture instance</a> with at least one provider configured.</li>\n<li>Admin access to the <a href=\"https://tailscale.com/docs/aperture/configuration\">Aperture configuration</a>.</li>\n<li>An HTTP or HTTPS endpoint that accepts POST requests with JSON payloads.</li>\n</ul>\n<h2><a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook#define-a-hook-endpoint\">Define a hook endpoint</a></h2>\n<p>Open the <strong>Settings</strong> page in the <a href=\"https://tailscale.com/docs/aperture/reference/dashboard\">Aperture dashboard</a> and add a <code>hooks</code> section to your configuration. Each hook has a unique key and specifies the endpoint URL:</p>\n<pre><code class=\"language-json\">\"hooks\": {\r\n  \"my-webhook\": {\r\n    \"url\": \"https://example.com/aperture-events\",\r\n    \"apikey\": \"YOUR_API_KEY\"\r\n  }\r\n}\n</code></pre>\n<p>If your endpoint uses a non-Bearer authentication scheme, set the <code>authorization</code> field:</p>\n<pre><code class=\"language-json\">\"hooks\": {\r\n  \"my-webhook\": {\r\n    \"url\": \"https://example.com/aperture-events\",\r\n    \"apikey\": \"YOUR_API_KEY\",\r\n    \"authorization\": \"x-api-key\",\r\n    \"timeout\": \"10s\"\r\n  }\r\n}\n</code></pre>\n<p>The <code>authorization</code> field supports <code>bearer</code> (default), <code>x-api-key</code>, and <code>x-goog-api-key</code>. The <code>timeout</code> field accepts Go duration strings such as <code>5s</code>, <code>30s</code>, or <code>1m</code> and defaults to <code>5s</code>.</p>\n<p>A hook defined in the <code>hooks</code> section has no effect until a grant references it.</p>\n<h2><a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook#trigger-the-hook-from-a-grant\">Trigger the hook from a grant</a></h2>\n<p>Add a <code>send_hooks</code> entry to a capability in your <code>grants</code> section. This controls which requests trigger the hook and what data Aperture includes in the payload:</p>\n<pre><code class=\"language-json\">\"grants\": [\\\r\n  {\\\r\n    \"src\": [\"*\"],\\\r\n    \"app\": {\\\r\n      \"tailscale.com/cap/aperture\": [\\\r\n        {\\\r\n          \"models\": \"**\",\\\r\n          \"send_hooks\": [\\\r\n            {\\\r\n              \"name\": \"my-webhook\",\\\r\n              \"events\": [\"entire_request\"],\\\r\n              \"send\": [\"estimated_cost\"]\\\r\n            }\\\r\n          ]\\\r\n        }\\\r\n      ]\\\r\n    }\\\r\n  }\\\r\n]\n</code></pre>\n<h3><a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook#select-hook-events\">Select hook events</a></h3>\n<p>The <code>events</code> array specifies when Aperture calls the hook:</p>\n<p>| Event | Description |\r\n| --- | --- |\r\n| <code>entire_request</code> | Fires for every completed request. |\r\n| <code>tool_call_entire_request</code> | Fires once after the response completes if any message in the response contained tool calls. |</p>\n<h3><a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook#select-data-types\">Select data types</a></h3>\n<p>The <code>send</code> array specifies which data to include in the POST payload:</p>\n<p>| Type | Description |\r\n| --- | --- |\r\n| <code>tools</code> | Array of tool calls extracted from the response. |\r\n| <code>request_body</code> | The original request body sent to the LLM. |\r\n| <code>user_message</code> | The user's message from the request. |\r\n| <code>response_body</code> | The reconstructed response body JSON. |\r\n| <code>raw_responses</code> | Array of raw SSE messages (for streaming) or single response object. |\r\n| <code>estimated_cost</code> | Dollar cost estimate, pricing basis, and token usage breakdown. |\r\n| <code>grants</code> | Non-Aperture app capabilities from the user's grants. |\r\n| <code>quotas</code> | Current state of all quota buckets that applied to this request. |</p>\n<h2><a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook#understand-the-payload-format\">Understand the payload format</a></h2>\n<p>Every hook call includes a <code>metadata</code> object with request context, regardless of what you specify in <code>send</code>:</p>\n<pre><code class=\"language-json\">{\r\n  \"metadata\": {\r\n    \"login_name\": \"alice@example.com\",\r\n    \"user_agent\": \"curl/8.0\",\r\n    \"url\": \"/v1/chat/completions\",\r\n    \"model\": \"gpt-5\",\r\n    \"provider\": \"openai\",\r\n    \"tailnet_name\": \"example.com\",\r\n    \"stable_node_id\": \"n12345\",\r\n    \"request_id\": \"abc123\",\r\n    \"session_id\": \"oacc_1a2b3c4d5e6f7890\"\r\n  }\r\n}\n</code></pre>\n<p>When you include data types in the <code>send</code> array, Aperture adds them to the payload alongside <code>metadata</code>. For example, with <code>\"send\": [\"tools\", \"estimated_cost\"]</code>:</p>\n<pre><code class=\"language-json\">{\r\n  \"metadata\": {\r\n    \"login_name\": \"alice@example.com\",\r\n    \"...\": \"...\",\r\n    \"estimated_cost\": {\r\n      \"dollars\": 0.0342,\r\n      \"cost_basis\": \"anthropic/claude-sonnet-4-5\",\r\n      \"usage\": {\r\n        \"input_tokens\": 1500,\r\n        \"output_tokens\": 800,\r\n        \"cached_tokens\": 200,\r\n        \"reasoning_tokens\": 0\r\n      }\r\n    }\r\n  },\r\n  \"tool_calls\": [...]\r\n}\n</code></pre>\n<h2><a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook#verify-the-webhook\">Verify the webhook</a></h2>\n<p>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:</p>\n<ol>\n<li>Confirm the hook name in <code>send_hooks</code> matches the key in the <code>hooks</code> section.</li>\n<li>Confirm the grant's <code>src</code> and <code>models</code> patterns match your test request.</li>\n<li>Check the Aperture server logs for timeout or connection errors to the hook URL.</li>\n</ol>\n<p>To temporarily disable a hook without removing it from the configuration, set <code>\"disabled\": true</code> in the hook definition.</p>\n<h2><a href=\"https://tailscale.com/docs/aperture/how-to/build-custom-webhook#next-steps\">Next steps</a></h2>\n<ul>\n<li><a href=\"https://tailscale.com/docs/aperture/how-to/export-usage-data-to-s3\">Export usage data to S3</a> for long-term storage of session logs.</li>\n<li><a href=\"https://tailscale.com/docs/aperture/how-to/integrate-oso\">Integrate Oso with Aperture</a> or <a href=\"https://tailscale.com/docs/aperture/how-to/integrate-cerbos\">Integrate Cerbos with Aperture</a> for policy engine integrations that use hooks.</li>\n<li>Refer to the <a href=\"https://tailscale.com/docs/aperture/configuration#hooks\">hooks configuration reference</a> for the complete field reference and additional examples.</li>\n</ul>\n<p><img src=\"https://cdn.brandfetch.io/tailscale.com/fallback/lettermark/theme/dark/h/256/w/256/icon?c=1bfwsmEH20zzEfSNTed\" alt=\"Project Logo\"></p>\n<p>Ask AI</p>\n<p>reCAPTCHA</p>\n<p>Recaptcha requires verification.</p>\n<p>protected by <strong>reCAPTCHA</strong></p>\n"}