Tunnel delivers signed POSTs 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: 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:
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.
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: 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.
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.
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.