# VPN webhooks

> Subscribe to VPN session and credential events, verify signed deliveries, and inspect or redeliver them from the delivery log.

VPN webhooks are signed HTTP POSTs that TeleQuick sends when VPN
sessions connect, close or get rejected, and when credentials are
revoked. They run on the same unified webhook plane as every other
vertical (`webhooks.*` procedures), with the endpoint's `vertical` set
to `vpn`.

The console screen at **VPN → Webhooks** is a thin client over those
procedures: it creates endpoints, renders the subscribable event groups
from the server-side registry, sends signed test pings, and shows the
delivery log with redelivery.

## What you can subscribe to

The event-group picker is **not** hardcoded in the console. It renders
from `webhooks.catalog`, which returns the BFF's webhook registry keyed
by vertical. That means the picker can never offer a token that no
producer emits, and the registry is the authoritative list of `vpn.*`
event types for your build of the platform.

Read it directly to enumerate the tokens:

```ts
const catalog = await trpc.webhooks.catalog.query({ orgId });

catalog.vpn.groups;
// [
//   { key, label, description, types: ["vpn.…", "vpn.…"] },
//   …
// ]
```

Each group has the shape:

| Field         | Type       | Meaning                                                     |
| ------------- | ---------- | ----------------------------------------------------------- |
| `key`         | `string`   | Stable group id. This is what the picker checkboxes toggle.  |
| `label`       | `string`   | Human label shown in the console row and on endpoint rows.   |
| `description` | `string`   | One-line description of the group.                           |
| `types`       | `string[]` | The concrete event type tokens the group expands to.         |

When you create or update an endpoint you submit **event types**, not
group keys — the console expands the checked groups into their `types`
before calling `webhooks.create`. `create` and `update` both run
`assertKnownEventTypes`, so a token that is not in the registry is
rejected as a bad request.

## Session lifecycle events

The VPN group set covers the session lifecycle: a session **connecting**,
a session **closing**, and a connection attempt being **rejected**. A
rejection event is what you subscribe to if you need to see attempts
that never became a session, rather than only completed sessions.

The exact token for each of these — and any additional session events in
your build — comes from the `types` array of the corresponding group in
`webhooks.catalog`. Subscribe by group rather than by literal token
where you can: group membership is maintained server-side, so an
endpoint subscribed to a whole group keeps working when the group gains
a type.

## Credential and access events

The second VPN concern is credentials: TeleQuick emits an event when a
VPN credential is **revoked**. Revocation events are the ones that
security tooling normally cares about most, because they are the signal
that an issued credential can no longer be used — pair them with the
session events above to reconstruct "who could connect, and who did".

As with session events, take the literal tokens from the credential
group's `types` in the catalog response.

## Event type matching

The dispatcher matches a delivery's event type against the endpoint's
`event_types` array using three forms, and the console applies the same
contract when it decides which group labels to show on an endpoint row:

| Pattern   | Matches                                                   |
| --------- | --------------------------------------------------------- |
| `*`       | Every event type, including types added to the registry later. |
| `vpn.*`   | Any type whose name starts with the prefix before the `*`. |
| exact     | Only that one event type.                                 |

If you tick every group in the picker, the console collapses the
selection to `['*']` rather than listing the current tokens. That is a
deliberate difference in behaviour: a `*` endpoint receives future event
types automatically, while an endpoint pinned to explicit tokens does
not.

## Create an endpoint and store the signing secret

`webhooks.create` takes the org, the vertical, the URL, an optional
description, the event types, and the private-egress flag:

```ts
const { id, secret } = await trpc.webhooks.create.mutate({
  orgId,
  vertical: "vpn",
  url: "https://your.app/hooks/telequick",
  description: "SIEM ingest - VPN session events",
  eventTypes: ["*"],
  allowPrivateEgress: false,
});
```

Input constraints enforced by the procedure and the console form:

| Field                | Rule                                                                 |
| -------------------- | -------------------------------------------------------------------- |
| `url`                | Required. `https://` only unless `allowPrivateEgress` is set. Max 2048 characters. The server also resolves the target and rejects one it cannot deliver to. |
| `description`        | Optional, max 300 characters.                                        |
| `eventTypes`         | Must all be known to the registry.                                   |
| `resourceRef`        | Optional, max 120 characters. Free-form reference to the resource the endpoint belongs to. |
| `vertical`           | `vpn` for a VPN-scoped endpoint, or `null` for an org-wide endpoint that receives every vertical's events. |

The response contains the signing secret in cleartext **once**. The row
stores it sealed, plus a `signing_secret_preview` (the first 12
characters) for display. The console shows the secret in a
copy-once card; if you lose it, your only option is
`webhooks.rotateSecret`.

## Verify the signature

Every POST carries a signature header:

```
X-TeleQuick-Signature: t=<unix-seconds>,v1=<hex>
```

where `v1` is `hex(hmac_sha256(secret, "<t>.<body>"))`. Compute it over
the **raw** request body, before any JSON parsing or re-serialisation,
and compare with a constant-time equality check. HTTP header names are
case-insensitive, so match the header without regard to case.

## Test sends

`webhooks.sendTest` delivers a signed `test.ping` to one endpoint
immediately and returns the outcome synchronously:

| Field        | Meaning                                             |
| ------------ | --------------------------------------------------- |
| `ok`         | Whether the receiver accepted the delivery.         |
| `statusCode` | The HTTP status the receiver returned, when there was one. |
| `error`      | Transport or receiver error text, when `ok` is false. |

Use it to prove signature verification works on your side before you
depend on real events. The test delivery is written to the delivery log
like any other.

## Delivery log and redelivery

`webhooks.deliveries.list` is the delivery log. It is org-scoped and
takes optional filters:

| Input        | Effect                                                                 |
| ------------ | ---------------------------------------------------------------------- |
| `endpointId` | Restrict to one endpoint.                                              |
| `vertical`   | Restrict to that vertical's endpoints plus org-wide (`vertical IS NULL`) endpoints. Applied server-side. |
| `status`     | One of `pending`, `delivering`, `delivered`, `dead`.                   |
| `limit`      | 1–200, default 50.                                                     |
| `page`       | 1-based page, ordered by `created_at` descending.                      |

Pass `vertical: 'vpn'` rather than filtering client-side. A busy org with
heavy voice or streams traffic can fill an unfiltered page entirely with
other verticals' rows.

Each row carries `created_at`, `event_type`, `endpoint_id`, `status`,
`attempts`, `last_status_code` and `last_error`. Payloads are
deliberately excluded from the list because they are large — the console
fetches the full payload separately when you open a delivery's detail
drawer.

`webhooks.deliveries.redeliver({ orgId, id })` queues a row again. The
console offers it for rows in `delivered` or `dead` status — that is, for
ones that are no longer in flight.

## Pause, resume and rotate

`webhooks.update` covers the operational controls:

- `status: 'paused'` stops deliveries to the endpoint.
- `status: 'active'` resumes it **and** resets `consecutive_failures`,
  which is how you clear an endpoint the platform auto-paused into the
  `failing` state. The endpoint list shows that state as a `failing`
  pill together with the consecutive-failure count.
- `url` and `eventTypes` can be changed in place. A new URL is
  re-validated against the row's own `allow_private_egress` flag, so an
  endpoint created as public-only cannot be edited into an `http://`
  target.

`webhooks.rotateSecret` mints a new secret and seals it in place,
returning the cleartext once. **The old secret stops verifying
immediately**, so stage the new secret on your receiver first — the
console's confirmation dialog says the same thing.

Every one of create, update, rotate and delete records an audit entry
(`webhooks.endpoint.create`, `.update`, `.rotate_secret`) against the
endpoint id with the acting user.

## On-prem and private-network endpoints

By default an endpoint must be `https://` and must resolve to a
deliverable public target. Tick **On-prem / private-network endpoint**
(`allowPrivateEgress: true`) when the receiver is a collector inside your
own network: that flag is what permits RFC1918 targets and `http://`
URLs. Without it, `create` rejects an `http://` URL with a
`BAD_REQUEST`.

The flag is a property of the endpoint row, not of the request, and the
console marks such rows with `private egress` under the URL.

## Wiring deliveries into a SIEM

A practical shape for security tooling:

1. Create one `vertical: 'vpn'` endpoint per collector, and put the
   collector's name in `description` — the console shows the description
   in place of the secret preview, which makes the endpoint list readable
   during an incident.
2. Subscribe to all groups (`*`) if the SIEM should see every VPN event
   including types added later; subscribe to the session and credential
   groups explicitly if you want a fixed, reviewable set.
3. Verify the signature over the raw body and reject unsigned or
   mismatched requests at the collector's edge.
4. Set `allowPrivateEgress` if the collector is on-prem.
5. Monitor the `failing` state and `dead` deliveries. Poll
   `webhooks.deliveries.list` with `status: 'dead'` and
   `vertical: 'vpn'`; the console polls the log every 15 s and surfaces
   the same rows.
6. Backfill gaps with `webhooks.deliveries.redeliver` once the collector
   is healthy again.

## Related

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