# Tunnel webhook events

> The tunnel session events you can subscribe to, how event tokens are matched, and how to verify and reconcile signed deliveries.

Tunnel delivers signed `POST`s to your own HTTPS endpoints when tunnel
sessions open, close, or get rejected. Endpoints live on the shared
ClutchCall webhook plane (`webhooks.*` procedures) and are scoped to an
organization plus the `tunnel` vertical, so the **Webhooks** screen in the
tunnel console only ever shows and edits tunnel-facing endpoints.

This page covers the tunnel side: which event tokens exist, what the picker's
event groups mean, when each session event fires, and how to reconcile what
actually landed on your receiver. Signature construction, retries, and the
delivery plane itself are shared across verticals.

## Event types emitted by tunnel

The event tokens are not hardcoded in the console. The picker renders from
`webhooks.catalog`, which returns the BFF's webhook registry; the console reads
`catalog[tunnel].groups`. Because the picker can only offer what the registry
declares, it cannot offer a token that no producer emits.

Each group in the catalog has this shape:

| Field         | Meaning                                                          |
| ------------- | ---------------------------------------------------------------- |
| `key`         | Stable group id. What the checkbox state is keyed on.            |
| `label`       | Human name shown next to the checkbox, and in the endpoint list. |
| `description` | One-line explanation of what the group covers.                   |
| `types`       | The concrete event type tokens the group expands to.             |

So the authoritative list of tunnel event tokens for your deployment is the
union of `types` across the tunnel groups. To read it outside the console:

```ts
const catalog = await trpc.webhooks.catalog.query({ orgId });
const tunnelTypes = catalog.tunnel.groups.flatMap((g) => g.types);
```

The registry is also enforced on write. `webhooks.create` and
`webhooks.update` run `assertKnownEventTypes(...)` on the submitted
`eventTypes`, so a typo'd or retired token is rejected rather than silently
stored as a subscription that never fires.

Two tokens are worth calling out because they do not come from session
traffic:

- `test.ping` — what the **Test** button sends via `webhooks.sendTest`. It is a
  fully signed delivery that appears in the delivery log like any other, so it
  is the right way to prove signature verification and network reachability
  before you wait for real sessions.
- `*` — not an event, a wildcard subscription. See below.

## How event type matching works

The console applies exactly the matching contract the dispatcher applies. A
subscription pattern covers an event type if:

- the pattern equals the event type, or
- the pattern is `*`, or
- the pattern ends in `.*` and the event type starts with the prefix before the
  `*`.

That has two consequences you will see in the UI:

- **Selecting every group collapses to `['*']`.** An endpoint stored as `*` is
  subscribed to everything in the tunnel registry *including event types added
  to the registry later*. If you want a frozen subscription set, deselect at
  least one group so the endpoint stores explicit tokens.
- **The "Event groups" column is derived, not stored.** The row shows a group's
  label when the endpoint's stored patterns cover any of that group's types. An
  endpoint stored as `*` renders as every group label. Patterns that match no
  group at all are shown verbatim as raw tokens.

## Session lifecycle: when each event fires

Tunnel session events mark the three outcomes a session can have, in the
console's own terms: a session **opens**, a session **closes**, or a session
attempt is **rejected**.

- **Open** fires when a session is admitted. It is the first event for a
  session id and marks the start of the session's lifetime.
- **Close** fires when that session tears down. Paired with the open event for
  the same session, it bounds the session's duration.
- **Reject** fires when an attempt is refused. A rejected attempt never became
  a session, so it is reported by the reject event alone — there is no open or
  close event to pair with it.

Use the group `description` text in the picker as the per-deployment source of
truth for what is included in each group; the console renders that string
directly from the registry.

Delivery is per-event and independent, and the delivery plane retries. Do not
assume your receiver sees open before close for a given session — order by the
timestamps in the payload, not by arrival order.

## Subscribing an endpoint to session events

`webhooks.create` takes the org, the vertical (`tunnel`), the URL, an optional
description, the event tokens, and the private-egress flag. The console builds
`eventTypes` from the checked groups, collapsing to `['*']` when all are
checked.

Constraints the procedures enforce:

- **URL scheme.** `https://` by default. An `http://` URL is rejected with
  `BAD_REQUEST` unless the endpoint is created with `allowPrivateEgress: true`
  — the **On-prem / private-network endpoint** checkbox. That flag is also what
  permits RFC1918 targets.
- **Reachability.** Both create and update call
  `assertDeliverableOrBadRequest(url, allowPrivateEgress)`, so an undeliverable
  target fails at configuration time rather than becoming a dead endpoint.
- **Lengths.** URL up to 2048 characters; description up to 300.
- **Scheme changes on update.** `webhooks.update` validates a new URL against
  the *stored* `allow_private_egress` flag of that row, not against anything in
  the request. Moving an existing endpoint to `http://` therefore fails unless
  the row was created as on-prem.

The signing secret is returned **once**, by `create` and by `rotateSecret`. The
row afterwards keeps only a sealed secret and a short
`signing_secret_preview` for identification. Deleting the endpoint clears the
reveal panel, because that secret belonged to the deleted endpoint.

Rotation is immediate and single-sided: `rotateSecret` seals the new secret in
place, and the previous secret stops verifying at once. Update your receiver to
accept the new secret before, or immediately as, you rotate.

## Verifying a delivery

Every delivery carries a signature header whose value is
`t=<timestamp>,v1=hex(hmac_sha256(secret, "<t>.<body>"))`. The header name is
brand-specific; the console prints the exact header for your deployment in the
**Signing secret** panel when a secret is minted or rotated. HTTP header names
are case-insensitive, so match it case-insensitively in your receiver.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(headerValue: string, rawBody: string, secret: string) {
  const parts = new Map(
    headerValue.split(",").map((kv) => kv.split("=") as [string, string]),
  );
  const t = parts.get("t");
  const v1 = parts.get("v1");
  if (!t || !v1) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Verify against the **raw** request body. Any re-serialisation (a JSON parse and
re-stringify, a proxy that reformats the body) changes the bytes the HMAC was
computed over and the signature will not match.

## What the delivery log records

`webhooks.deliveries.list` backs the **Recent deliveries** table. The console
polls it every 15 s, requests 50 rows (the procedure defaults to 50 and accepts
up to 200 per page), and passes `vertical: 'tunnel'` so the log stays scoped to
tunnel endpoints plus org-wide endpoints rather than being filtered client-side.
Clicking an endpoint row filters the log to that `endpointId`.

Per delivery you get:

| Column             | Meaning                                                         |
| ------------------ | --------------------------------------------------------------- |
| `created_at`       | When the delivery was enqueued.                                 |
| `event_type`       | The event token that triggered it.                              |
| `endpoint_id`      | Which endpoint it was addressed to.                             |
| `status`           | `pending`, `delivering`, `delivered`, or `dead`.                |
| `attempts`         | Attempt count so far.                                           |
| `last_status_code` | HTTP status from the most recent attempt, or `—`.               |
| `last_error`       | Transport or application error text from the most recent attempt. |

`webhooks.deliveries.redeliver` re-queues a single delivery; the console exposes
it for rows in `delivered` and `dead` state.

Endpoint health is tracked on the endpoint row, not the delivery: `status`
(`active` / `paused` / `failing`), `consecutive_failures`, `last_delivery_at`,
and `last_status_code`. Setting an endpoint back to `active` through
`webhooks.update` both resumes it and zeroes `consecutive_failures`, which is
how you clear a `failing` endpoint after fixing the receiver.

## Worked example: session audit trail into a SIEM

The goal is a tamper-evident record of every session that opened, closed, or was
rejected, with no gaps.

**1. Create one endpoint subscribed to all tunnel groups.** Leave every group
checked so the endpoint is stored as `['*']` and automatically picks up event
types added to the registry later — for an audit trail you want the superset,
not a frozen list.

```ts
const { id, secret } = await trpc.webhooks.create.mutate({
  orgId,
  vertical: "tunnel",
  url: "https://siem-shim.internal.example/hooks/tunnel",
  description: "SIEM shim — session audit trail",
  eventTypes: ["*"],
  allowPrivateEgress: false,
});
// `secret` is returned once. Put it in your secret manager now.
```

If the shim only listens inside your network, create it with
`allowPrivateEgress: true` so an RFC1918 or `http://` target is accepted.

**2. Verify, then forward, then acknowledge.** Reject anything that fails
signature verification, and return a non-2xx only when you genuinely failed to
persist — a non-2xx increments `attempts` and `consecutive_failures`, and a
persistently failing endpoint ends up `failing`.

```ts
app.post("/hooks/tunnel", async (req, res) => {
  const sig = req.header("x-clutchcall-signature") ?? "";   // see note above
  const raw = req.rawBody.toString("utf8");

  if (!verify(sig, raw, process.env.TUNNEL_WEBHOOK_SECRET!)) {
    return res.status(401).end();
  }

  const event = JSON.parse(raw);
  await siem.ingest({ source: "tunnel", eventType: event.type, raw: event });
  res.status(204).end();
});
```

**3. Prove the pipe before you trust it.** Hit **Test** on the endpoint. That
sends a signed `test.ping` through the real dispatcher and writes a row to the
delivery log, so a failure here tells you whether the problem is your signature
check (`401` in `last_status_code`) or your network path (`last_error` with no
status code).

**4. Reconcile.** The delivery log is the reconciliation source. Filter it to
the SIEM endpoint, look for rows in `dead` state, fix the receiver, then set the
endpoint back to `active` (which clears `consecutive_failures`) and
**Redeliver** the dead rows. Because redelivery replays the same event, make
your SIEM ingest idempotent on the event's own identifier rather than on arrival
order.

**5. Rotate on a schedule.** `rotateSecret` invalidates the old secret
immediately, so sequence it as: stage the new secret in the shim so it accepts
both, rotate in the console, copy the revealed secret in, drop the old one.
Rotations are audited (`webhooks.endpoint.rotate_secret`), as are endpoint
creates and updates.

## Related

- [Authentication](/concepts/authentication) — API keys for the control plane
- [Telemetry](/platform/telemetry) — metrics and traces alongside webhook events
