# QuickDesk webhooks

> The QuickDesk event catalogue: peer presence and session lifecycle event types, how subscriptions match them, and how to wire and verify a handler.

QuickDesk endpoints live on the unified webhook plane. The console screen
creates them with `vertical: 'quickdesk'`, so they receive the QuickDesk
event catalogue: signed POSTs when remote-desktop peers come online or go
offline, and when remote-control sessions connect or get denied.

Signing, retry behaviour and the delivery log are shared across every
ClutchCall vertical — see [Webhooks](/platform/webhooks). This page
covers what is specific to QuickDesk: which event tokens exist, how the
group picker maps onto them, and what a handler sees.

## Event groups and types

The **Event groups** checklist on the webhooks screen is rendered from
`webhooks.catalog`, the control-plane registry, not from a hardcoded list
in the console. That is deliberate: the picker can never offer a token
that no producer emits, and `webhooks.create` / `webhooks.update` reject
unknown tokens outright (`assertKnownEventTypes`). The registry is
therefore the authoritative list; the table below is the QuickDesk slice
of it.

| Event type          | Group            | Fires when                                                        |
| ------------------- | ---------------- | ----------------------------------------------------------------- |
| `peer.online`       | Peer presence    | A remote-desktop peer comes online and is reachable for control.  |
| `peer.offline`      | Peer presence    | A peer goes offline.                                              |
| `session.connected` | Session lifecycle| A remote-control session is established against a peer.           |
| `session.denied`    | Session lifecycle| A remote-control session request is refused rather than connected. |

Each group in the picker carries its own `label` and `description`
straight from the registry, plus the list of types it expands to. Ticking
a group subscribes the endpoint to every type in that group.

`test.ping` is not part of any group. The **Test** button on each endpoint
row calls `webhooks.sendTest`, which delivers a signed `test.ping` to that
endpoint immediately and reports the HTTP status it got back. Use it to
prove your signature check works before real traffic arrives.

## How event types are matched

An endpoint stores an array of patterns in `event_types`. The dispatcher
matches a fired event against those patterns three ways, and the console
applies the identical contract when it decides which group badges to show
on a row:

- exact match — `session.denied` matches only `session.denied`
- `*` — matches every event type
- prefix glob `x.*` — `peer.*` matches `peer.online` and `peer.offline`

Two consequences worth knowing before you save an endpoint:

- **Selecting every group collapses to `*`.** The screen sends
  `eventTypes: ['*']` when all groups are ticked, which means the endpoint
  also receives event types added to the registry later. If you want a
  frozen contract, tick only the groups you handle.
- **Selecting nothing also stores `*`.** The screen falls back to `['*']`
  rather than creating an endpoint that can never fire.

To narrow or widen an existing endpoint, call `webhooks.update` with a new
`eventTypes` array; it is validated against the registry the same way as
on create.

## Wiring a handler for peer presence

Subscribe to the peer presence group only, and your endpoint sees
`peer.online` and `peer.offline` and nothing else — no session traffic,
and no future tokens from other groups.

Every delivery is a POST with a JSON body and a signature header. Verify
it over the raw request body, before parsing:

```ts
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post(
  "/hooks/quickdesk",
  express.raw({ type: "application/json" }),
  (req, res) => {
    // Header: t=<unix seconds>,v1=<hex>
    const header = String(req.header("X-ClutchCall-Signature") ?? "");
    const parts = Object.fromEntries(
      header.split(",").map((kv) => kv.split("=") as [string, string]),
    );

    const expected = crypto
      .createHmac("sha256", process.env.QUICKDESK_WEBHOOK_SECRET!)
      .update(`${parts.t}.${req.body.toString("utf8")}`)
      .digest("hex");

    if (
      !parts.v1 ||
      !crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
    ) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString("utf8"));

    switch (event.event_type) {
      case "peer.online":
        // mark the peer reachable in your own inventory
        break;
      case "peer.offline":
        // mark it unreachable; cancel anything queued against it
        break;
    }

    res.status(200).end();
  },
);
```

The signed value is `t` and the raw body joined with a `.`, HMAC-SHA256
with the endpoint's signing secret, hex encoded. The secret is returned
**once** — by `webhooks.create` at creation, and by
`webhooks.rotateSecret` on rotation. After that the console only shows
`signing_secret_preview`.

Rotation takes effect immediately: the previous secret stops verifying as
soon as `webhooks.rotateSecret` returns, so deploy the new secret on your
side first, or pause the endpoint while you swap it.

## Session connected and denied

`session.connected` and `session.denied` are the two terminal outcomes of
a remote-control session request against a peer. Treat them as a pair:

- `session.connected` — the session was established. This is the token to
  drive "session in progress" state, audit records, or an operator
  notification.
- `session.denied` — the request was refused instead of connecting. Nothing
  was established, so do not expect a later `session.connected` for the
  same request.

If you subscribe to the session group but not peer presence, your handler
sees session outcomes without presence churn — useful when a middleware
service only needs to record who connected to what.

## Reading the exact payload of an event

This page names the event tokens; the field set inside each payload comes
from the producer and is recorded verbatim on the delivery. Rather than
coding against a guess, read a real one:

1. Create the endpoint with the groups you care about.
2. Press **Test** on the endpoint row to confirm signature verification
   works end to end (`webhooks.sendTest` reports the HTTP status your
   server returned).
3. Trigger the real event, then open **Recent deliveries** and click into
   the row. The delivery detail carries the full stored payload; the list
   view carries only the metadata (`event_type`, `created_at`, `status`,
   `attempts`, `last_status_code`, `last_error`).

Clicking an endpoint row filters the delivery log to that endpoint;
otherwise the log shows deliveries for this vertical's endpoints. Rows in
`delivered` or `dead` state can be replayed with **Redeliver**, which
queues the same payload again — a convenient way to re-run a handler you
have just fixed.

## Endpoint state you will see on the screen

| State                  | Meaning                                                                                 |
| ---------------------- | --------------------------------------------------------------------------------------- |
| `active`               | Delivering.                                                                             |
| `paused`               | Set by you via **Pause**; no deliveries are attempted.                                  |
| `failing`              | Auto-paused after repeated failures. The row also shows the consecutive-failure count.   |

Pressing **Resume** sets `status: 'active'` through `webhooks.update`,
which also clears the consecutive-failure counter and the `failing`
auto-pause. Delivery rows themselves move through `pending`, `delivering`,
`delivered` and `dead`.

## On-prem and private-network endpoints

By default an endpoint URL must be `https://` and must resolve to a
publicly deliverable address — `webhooks.create` runs a deliverability
check and rejects anything else with `BAD_REQUEST`. Tick **On-prem /
private-network endpoint** to set `allowPrivateEgress`, which permits
RFC1918 targets and `http://` URLs. A plain `http://` URL without that
flag is rejected on both create and update, and the flag stored on the row
— not the flag in the form — decides what a later URL change may point at.

## Related

- [Webhooks](/platform/webhooks) — signing, retries, and the delivery log shared by all verticals
- [Authentication](/concepts/authentication) — API keys for the control-plane procedures behind this screen
- [Telemetry](/platform/telemetry) — metrics and traces for the same events
