# Robotics webhook events

> How fleet event groups and event-type tokens work, how a subscription matches an event, and how to verify and replay robot-discovery deliveries.

Robotics (Optimus) webhook endpoints live on the same unified webhook plane as
the rest of ClutchCall: they are org-scoped rows created through
`webhooks.create` with `vertical: 'fleet'`. A fleet endpoint receives a signed
`POST` when something changes on the robot graph — for example when a ROS2
endpoint is discovered on a robot that has just come online.

Only the Optimus console mounts this screen. Arena (games) emits no webhook
events, so there is no webhooks screen there.

## Event groups and tokens

An endpoint subscribes to **event-type tokens**. Tokens are grouped for the
picker, and both the groups and their tokens come from the server-side registry
returned by `webhooks.catalog`, under the `fleet` key:

| Field         | Meaning                                                        |
| ------------- | -------------------------------------------------------------- |
| `key`         | Stable identifier for the group. Used by the picker only.       |
| `label`       | Human name shown next to the checkbox.                         |
| `description` | One-line summary of what the group covers.                      |
| `types`       | The event-type tokens the group expands to on create / update. |

The registry is the single source of truth in both directions:

- The picker can only offer groups and tokens that a producer actually emits,
  because it renders straight from `webhooks.catalog`.
- `webhooks.create` and `webhooks.update` run every submitted token through the
  same registry check. A token that is not in the registry is rejected with
  `BAD_REQUEST`, so a hand-written `eventTypes` array cannot subscribe to a
  typo.

To see the exact fleet groups and tokens for your org, call
`webhooks.catalog({ orgId })` and read `fleet.groups`, or open the **Add
endpoint** card in the console — the two render the same data.

One token exists outside the group picker: `test.ping`. The **Test** button on
an 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 signature verification works before you wait for real robot traffic.

## How a subscription matches an event type

An endpoint stores a list of patterns in `event_types`. When an event is
produced, the dispatcher matches it against each pattern using three rules:

| Pattern   | Matches                                                            |
| --------- | ------------------------------------------------------------------ |
| `*`       | Every event type, including types added to the registry later.      |
| `foo.bar` | Exactly the type `foo.bar`.                                         |
| `foo.*`   | Every type whose name starts with `foo.` — a single-prefix glob.    |

There is no deeper wildcard syntax: `*` in the middle of a token is not
special, and a trailing `.*` only matches on the literal prefix before it.

The console applies the same matching contract in reverse to label an existing
endpoint. For each configured group it checks whether the endpoint's patterns
cover the group's tokens, and shows the matching group labels as chips. If the
endpoint stores `*`, every group label is shown. If the patterns cover no
group cleanly — for example after you set `eventTypes` by hand — the raw tokens
are shown instead.

## Choosing between '*' and per-group subscriptions

The picker starts with every group ticked. When **all** groups are ticked, the
console does not send the expanded token list; it collapses the selection to
the single pattern `['*']`. That distinction matters:

- **`*` (all groups ticked)** — the endpoint receives event types that are
  added to the registry after you created it. Pick this for a general fleet
  middleware or event bus that forwards everything downstream and filters on
  its own side. Your receiver must tolerate unknown event types without
  failing the delivery.
- **Per-group tokens (some groups ticked)** — the endpoint stores the explicit
  token list for the ticked groups, and nothing else. New event types added to
  the registry later will *not* be delivered until you update the endpoint.
  Pick this when the receiver switches on a closed set of types, or when a
  downstream system must not see unrelated fleet traffic.

Untick everything and the console still submits `['*']` — an endpoint with no
subscription would never fire, so "none" is treated as "all". To narrow an
existing endpoint, call `webhooks.update({ orgId, id, eventTypes })` with the
token list you want; the registry check runs again on update.

## Creating an endpoint

`webhooks.create` takes the destination, an optional description, the token
list, and the private-egress flag:

```ts
const { id, secret } = await webhooks.create.mutate({
  orgId,
  vertical: "fleet",
  url: "https://your.app/hooks/clutchcall",
  description: "Fleet middleware — robot discovery",
  eventTypes: ["*"],
  allowPrivateEgress: false,
});
```

Constraints enforced at create time:

- The URL must be `https://` unless `allowPrivateEgress` is set. An `http://`
  URL without that flag is rejected with `BAD_REQUEST`.
- The URL is checked for deliverability before the row is written. An address
  the delivery worker is not allowed to reach fails as `BAD_REQUEST` rather
  than becoming an endpoint that can never succeed.
- `url` accepts up to 2000 characters; `description` up to 300.
- Every entry in `eventTypes` must exist in the registry.

The response contains the **signing secret in cleartext exactly once**. The row
stores a sealed copy plus a 12-character preview for display. Copy the secret
when the console reveals it — reopening the screen will not show it again.

`webhooks.rotateSecret({ orgId, id })` mints a replacement and returns it once.
The previous secret stops verifying immediately, so roll the new value into
your receiver first, then rotate.

Creates, updates and secret rotations are all written to the org audit log.

## Verifying a delivery

Each delivery carries a signature header built from the endpoint's secret:

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

where `v1` is `hmac_sha256(secret, "<t>.<raw request body>")`. HTTP header names
are case-insensitive, so match it case-insensitively rather than byte-for-byte.

Verify against the **raw** body bytes, before any JSON parsing or
re-serialisation, and compare digests in constant time. Also compare `t`
against your own clock and reject deliveries whose timestamp is too far out for
your tolerance — the signature alone does not stop a captured request from
being replayed later.

## Private-network endpoints

`allowPrivateEgress` is a security switch, not a convenience toggle. Setting it:

- permits `http://` destinations, and
- permits RFC1918 / private-network targets, which the usual egress guard would
  otherwise refuse.

Cloud metadata addresses stay blocked whether or not the flag is set.

Only set it for a receiver you operate inside your own network. The flag is
stored on the endpoint row and governs later URL edits too: `webhooks.update`
re-validates a new URL against the *existing* row's flag, so an endpoint
created without private egress cannot be edited into an `http://` target. The
console marks such endpoints with a `private egress` note under the URL.

## Endpoint status and failure handling

Each endpoint row carries the state the console renders:

| Field                   | Meaning                                                      |
| ----------------------- | ------------------------------------------------------------ |
| `status`                | `active`, `paused`, or `failing`.                             |
| `consecutive_failures`  | Failed deliveries since the last success. Shown when non-zero.|
| `last_delivery_at`      | Timestamp of the most recent attempt.                         |
| `last_status_code`      | HTTP status of the most recent attempt.                       |
| `allow_private_egress`  | The private-network flag described above.                     |
| `signing_secret_preview`| First 12 characters of the secret, for identifying the row.    |

`webhooks.update({ orgId, id, status })` accepts `active` or `paused`.
**Pause** stops deliveries while keeping the endpoint and its secret. Setting
`status: 'active'` both resumes a paused endpoint and clears a `failing`
endpoint: it resets `consecutive_failures` to zero, which is how you recover an
endpoint that was auto-paused after repeated failures.

`webhooks.remove` deletes the endpoint and deliveries stop immediately.

## Delivery log and redelivery

`webhooks.deliveries.list` returns attempt records newest-first. It accepts
`endpointId`, `vertical`, and `status` filters, plus `limit` (max 200, default
50) and `page`. Passing `vertical: 'fleet'` scopes the query server-side to
fleet endpoints **plus** org-wide endpoints whose `vertical` is null — prefer
that over fetching org-wide rows and filtering in your own code, which starves
the fleet log on orgs with busy voice or streams traffic.

Each row reports:

| Field              | Meaning                                                         |
| ------------------ | --------------------------------------------------------------- |
| `created_at`       | When the delivery was queued.                                    |
| `event_type`       | The token that fired this delivery.                              |
| `endpoint_id`      | Which endpoint it was addressed to.                              |
| `status`           | `pending`, `delivering`, `delivered`, or `dead`.                 |
| `attempts`         | Number of attempts made so far.                                  |
| `last_status_code` | HTTP status of the last attempt, if any.                          |
| `last_error`       | Error text from the last attempt, if any.                         |

Payload bodies are deliberately excluded from the list response — they are
large, and the console loads a single delivery's full payload only when you
open its detail drawer.

`webhooks.deliveries.redeliver({ orgId, id })` re-queues one delivery. The
console offers it for `dead` deliveries (recovering events lost while your
receiver was down) and for `delivered` ones (replaying an event against a fixed
receiver). Because a replay is a fresh attempt of the same event, your receiver
must be idempotent.

## Worked receiver example

An Express receiver that verifies the signature over the raw body and
acknowledges the delivery:

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

const app = express();
const SECRET = process.env.FLEET_WEBHOOK_SECRET!;
const MAX_SKEW_SECONDS = Number(process.env.FLEET_WEBHOOK_MAX_SKEW ?? 300);

// Raw body — verification must run on the exact bytes that were signed.
app.post(
  "/hooks/clutchcall",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.header("x-clutchcall-signature");
    if (!header) return res.status(400).send("missing signature");

    const parts = new Map(
      header.split(",").map((kv) => {
        const i = kv.indexOf("=");
        return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()] as const;
      }),
    );
    const t = parts.get("t");
    const v1 = parts.get("v1");
    if (!t || !v1) return res.status(400).send("malformed signature");

    // Your own replay window — the platform does not choose one for you.
    if (Math.abs(Date.now() / 1000 - Number(t)) > MAX_SKEW_SECONDS) {
      return res.status(400).send("stale timestamp");
    }

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${t}.`)
      .update(req.body as Buffer)
      .digest("hex");

    const ok =
      expected.length === v1.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
    if (!ok) return res.status(401).send("bad signature");

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

    // Acknowledge first, then do the work out-of-band. A slow handler turns
    // into a failed attempt and a retry.
    res.status(204).end();
    void handleFleetEvent(event);
  },
);
```

Two things this example is doing deliberately:

- **It does not reject unknown event types.** An endpoint subscribed with `*`
  will start receiving tokens added to the registry after it was created, and a
  receiver that throws on an unrecognised type turns every one of those into a
  retried failure and eventually a `failing` endpoint.
- **It treats redelivery as normal.** The same event can arrive more than once
  after a retry or a manual **Redeliver**, so `handleFleetEvent` must key off
  the event's identifier and be safe to run twice.

Start by pointing this receiver at a new endpoint and pressing **Test** in the
console: `webhooks.sendTest` reports the HTTP status your receiver returned, so
a signature bug shows up as a `401` in the result message instead of as silent
missing traffic later.

## Related

- [Authentication](/concepts/authentication) — API keys and org scoping for control-plane calls
- [Telemetry](/platform/telemetry) — metrics and traces for the delivery path
