# Streams webhooks

> How the Streams webhook picker builds event-type subscriptions, how patterns are matched, and how endpoints are signed, tested, paused, and rotated.

Streams webhooks are signed HTTP `POST`s from ClutchCall to a URL you own.
You manage them on the **Webhooks** screen, which is wired to the unified
webhook plane (`webhooks.*`) with `vertical: 'streams'`. Each row in the table
is one `webhook_endpoint`: a URL, an event-type subscription, a signing secret,
and a health state.

This page explains what the event picker on that screen is actually storing and
how the dispatcher interprets it. Signing, retry/backoff behaviour, and refused
destinations are covered on the [platform webhooks page](/platform/webhooks).

## Event types

The picker is **catalog-driven**. The console calls `webhooks.catalog` and reads
`streams.groups` from the returned registry; each entry is a group of concrete
event-type tokens:

| Field         | Meaning                                                                        |
| ------------- | ------------------------------------------------------------------------------ |
| `key`         | Identity of the checkbox in the picker. Not stored on the endpoint.            |
| `label`       | The group name shown in the dialog.                                            |
| `description` | The one-line hint under the label.                                             |
| `types`       | The event-type tokens written to `event_types` when the group is checked.      |

The authoritative list of Streams event types is therefore whatever the registry
serves your org — the picker renders exactly that set and nothing else. This is
deliberate: the screen replaced a free-text event field that happily stored
typos which no producer ever emitted. The server enforces the same rule, so
subscriptions created through the API cannot drift either: `webhooks.update`
runs `assertKnownEventTypes` on any `eventTypes` you send and rejects unknown
tokens.

Groups exist only in the UI. What lands on the endpoint row is a flat array of
patterns.

## How event patterns are matched

The dispatcher matches a fired event type against the endpoint's stored
patterns using three forms:

| Pattern     | Matches                                                        |
| ----------- | -------------------------------------------------------------- |
| `*`         | Every event type, including types added to the registry later. |
| `some.type` | That exact event type.                                         |
| `some.*`    | Every event type whose name starts with `some.`.               |

The console applies the identical rule in reverse when you open **Edit**: a
group's checkbox is pre-checked only when *every* type in that group is covered
by the row's stored patterns. A row stored as `*` therefore opens with all
groups checked.

## Choosing groups vs '*'

What the forms store depends on how many groups you check:

- **All groups checked** collapses to `['*']`. The endpoint then also receives
  event types that are added to the registry after you saved it. The dialog
  says so under the checkbox list.
- **Some groups checked** stores the concatenated `types` of those groups as an
  explicit list. New event types added to the registry later are **not**
  delivered to that endpoint until you re-open **Edit** and save again.
- **No groups checked** resolves to an empty array, which fails validation with
  *"Pick at least one event group"*. An empty subscription is never silently
  turned into "everything".

Pick `*` when your handler switches on the event type and ignores what it does
not recognise. Pick individual groups when your handler is strict, or when you
want to keep delivery volume to one endpoint narrow.

## Endpoint URL rules

By default an endpoint URL must be `https://`. Ticking **On-prem /
private-network endpoint** at create time sets `allowPrivateEgress`, which
permits `http://` and RFC1918 targets for that row.

The flag is stored on the row and is what the server consults afterwards.
`webhooks.update` loads the existing endpoint and rejects an `http://` URL with
`http:// endpoints require private egress (on-prem targets only)` unless the row
was created with private egress; the URL is then re-checked for deliverability
before the patch is applied. The **Edit** dialog mirrors this, so a row created
as public-only will not let you paste an `http://` URL.

The optional **Description** is free text (max 300 characters) and is shown
under the URL in the table. Use it to record where deliveries land.

## Signing secret and rotation

Each endpoint has its own HMAC secret (`whsec_…`). It is shown **once** — in the
dialog that appears right after create, and again after a rotate. The stored
row keeps only a sealed value plus a short preview, so there is no way to read
the secret back later.

Set the secret in your endpoint and verify the signature header on every
delivery; the secret dialog names the exact header.

**Rotate** mints a new secret and the previous one stops verifying
*immediately* — there is no overlap window. If your endpoint validates
signatures, update your side first, then rotate, or accept a gap in which
deliveries fail signature checks.

## Testing an endpoint

**Test** calls `webhooks.sendTest`, which enqueues a signed `test.ping` envelope
directly to that endpoint. The test bypasses the subscription: it is delivered
regardless of which groups you checked.

The procedure then waits for the delivery worker's verdict, polling the delivery
row for up to 12 seconds, and reports one of:

| Result                    | Meaning                                                                                  |
| ------------------------- | ---------------------------------------------------------------------------------------- |
| `Test delivered (HTTP n)` | The delivery reached status `delivered`. `n` is the response code your endpoint returned. |
| `Test failed (HTTP n)`    | The worker got a verdict and it was not a success. The message carries the last error.    |
| No verdict yet            | Nothing was recorded inside the poll window — usually the delivery worker is not running. Check the delivery log. |

A test is audit-logged with its `ok` flag and status code.

## Endpoint health states

The **Health** column is derived, not stored as a single field:

| Shown             | Condition                                                               |
| ----------------- | ----------------------------------------------------------------------- |
| `Paused`          | `status = 'paused'`.                                                    |
| `Failing (n×)`    | `consecutive_failures > 0`. `n` is that counter.                        |
| `Healthy`         | A delivery has been attempted (`last_delivery_at` is set) and the failure counter is zero. |
| `Untested`        | No delivery has ever been attempted against this endpoint.              |

A brand-new endpoint is **Untested**, not green. Green is earned by an actual
delivery — send a test if you want to confirm the URL before events start
flowing. The **Last status** column shows the most recent HTTP status code, or
`—` if there has never been one.

## Pausing, resuming, and deleting

**Pause** flips the row to `status: 'paused'` through `webhooks.update`; the
endpoint receives no deliveries until it is resumed. **Resume** sets
`status: 'active'` and, in the same patch, clears `consecutive_failures` — so
resuming also clears an endpoint that the platform auto-paused for repeated
failures.

**Delete** soft-deletes the endpoint. Deliveries to it stop immediately and the
action cannot be undone. Endpoint create, update, secret rotation, and test are
all recorded in the org audit log.

## Where deliveries are listed

This screen manages endpoints only. Individual deliveries — their payloads,
statuses, and response codes — live on the **Request logs** page (`/logs`).
`webhooks.list` backs that view: it resolves the org's `streams` endpoints
(plus org-wide rows whose `vertical` is null) and returns delivery rows for
them, newest first, optionally filtered by delivery status (`pending`,
`delivering`, `delivered`, `dead`). Full payloads are fetched separately for the
detail drawer rather than in the list.

## Plan entitlement

**Add endpoint** is disabled when the org's plan does not include the Streams
product. The button explains this on hover; existing endpoints remain visible.

## Related

- [Webhooks (platform)](/platform/webhooks) — signature construction, retry and
  backoff, refused destinations
- [Authentication](/concepts/authentication) — API keys and org-scoped access to
  the control plane
