# Realtime webhooks

> Subscribe an HTTPS endpoint to realtime event groups, verify signed deliveries, and inspect or redeliver from the delivery log.

Realtime webhooks live on the unified webhook plane. The same
`webhooks.*` tRPC router backs every product, and realtime endpoints are
rows in the shared `webhook_endpoint` table with `vertical = 'realtime'`.
ClutchCall POSTs signed event batches to the URLs you register, and
records every attempt in a delivery log you can page through and replay.

Endpoints are scoped to your organization, not to a single app. An
endpoint may optionally be narrowed to one realtime app.

| Procedure                       | Input                                                        | Result                          |
| ------------------------------- | ------------------------------------------------------------ | ------------------------------- |
| `webhooks.catalog`              | `orgId`                                                       | Event-group registry per vertical |
| `webhooks.list`                 | `orgId`, `vertical`                                           | Endpoint rows                   |
| `webhooks.create`               | `orgId`, `vertical`, `resourceRef`, `url`, `eventTypes`       | Endpoint + signing secret (once) |
| `webhooks.update`               | `orgId`, `id`, `status`                                       | Pause / resume                  |
| `webhooks.rotateSecret`         | `orgId`, `id`                                                 | New signing secret (once)       |
| `webhooks.sendTest`             | `orgId`, `id`                                                 | `ok`, `statusCode`, `error`     |
| `webhooks.remove`               | `orgId`, `id`                                                 | Deletes the endpoint            |
| `webhooks.deliveries.list`      | `orgId`, `endpointId?`, `vertical`, `limit`, `page`           | Delivery records (no payload)   |
| `webhooks.deliveries.get`       | `orgId`, `id`                                                 | One delivery + payload + error  |
| `webhooks.deliveries.redeliver` | `orgId`, `id`                                                 | Queues the delivery again       |

## Event groups and the event types they cover

You do not type event names by hand. `webhooks.catalog` returns the
registry of groups that the backend actually emits, keyed by vertical.
Read `catalog.realtime.groups`. Each group carries:

| Field         | Meaning                                              |
| ------------- | ---------------------------------------------------- |
| `key`         | Stable group id, e.g. `existence`, `presence`.       |
| `label`       | Display name shown in the console.                   |
| `description` | One-line summary of what the group covers.           |
| `types`       | The concrete event types the group expands to.       |

Realtime currently registers the **existence** and **presence** groups,
and the console pre-selects both when you open the add form.

The registry is the source of truth. An event type that no emitter
produces is removed from it, so the picker cannot offer a dead
subscription. `client_event` and `cache_miss` were offered by an earlier
console build and produced nothing; they are no longer in the registry.
If you have an old endpoint subscribed to those strings, it receives
nothing — re-pick the groups you want.

If the catalog fails to load, creation is blocked rather than defaulted.
An endpoint is never created with an empty or guessed subscription.

## Choosing groups versus subscribing to all events

The stored subscription is a list of patterns in `event_types`. The
dispatcher matches an outgoing event against those patterns with three
rules:

- exact match — `presence.member_added`
- the wildcard `*` — matches every event type
- a prefix glob ending in `.*` — `presence.*` matches any type starting
  with `presence.`

Selecting a subset of groups stores the union of those groups' concrete
`types`. Selecting **every** group collapses to the single pattern `*`.
The difference matters over time: a fixed list receives only the types
that existed when you created the endpoint, while `*` also receives
event types added to the registry later.

Pick specific groups when your handler switches exhaustively on event
type and would reject or misroute an unknown one. Pick `*` when your
handler ignores types it does not recognise.

In the endpoint list, the **Event groups** column reverses this mapping:
an endpoint on `*` shows every group label, an endpoint on concrete
types shows the labels of the groups those types belong to, and anything
that maps to no known group is shown as the raw patterns.

## App-scoped and org-wide endpoints

Two different scopes appear on this screen. Do not confuse them.

**Scope to app** (`resourceRef` on create) narrows a realtime endpoint to
one app. Leave it as *All apps* and `resourceRef` is `null`, so every
realtime app in the org delivers to that URL. When set, it holds the
app's public `appId`, and the endpoint row shows `app <appId>` under the
URL.

**Org-wide endpoints** are rows whose `vertical` is `null`. These are not
realtime-specific — they are shared with the other products, so voice and
streams deliver to them too. The console tags them `org-wide` and warns
you before pausing or deleting one, because the change affects those
other products' deliveries as well. Endpoints you create from this
screen are always `vertical = 'realtime'` and affect realtime only.

`webhooks.list` with `vertical: 'realtime'` returns both realtime rows
and the org-wide rows that will also receive realtime events.

## Signing secret and signature headers

`webhooks.create` returns the signing secret **once**, in the creation
response. It is sealed at rest afterwards, and the endpoint row exposes
only `signing_secret_preview` — a truncated prefix for identifying which
secret a row holds. Store the full value when it is shown.

Every request is signed with HMAC-SHA256 over the raw request body and
carries two headers with the same signature:

| Header                      | Purpose                                            |
| --------------------------- | -------------------------------------------------- |
| `X-Pusher-Signature`        | Pusher-compatible. Stock Pusher webhook verifiers work unchanged. |
| `X-ClutchCall-Signature`  | The platform header.                               |

Verify before you trust the payload:

```
hmac_sha256(body, signing_key)
  === request.headers['X-Pusher-Signature']
```

Compute the HMAC over the exact bytes you received, before any JSON
parsing or re-serialisation.

## Rotating the signing secret

`webhooks.rotateSecret` mints a new secret and returns it once, exactly
like create. **The current secret stops verifying immediately** — there is
no overlap window — so rotation is a cutover, not a gradual roll:

1. Deploy a handler that accepts either the old or the new secret, or
   accept a short window of rejected deliveries.
2. Call `webhooks.rotateSecret`, or press **Rotate** on the row and confirm.
3. Store the new secret and drop the old one from your handler.
4. Replay anything that failed during the cutover from the delivery log.

## Endpoint states and consecutive failures

Each endpoint row carries a `status` and a failure counter.

| Status    | Console pill      | Meaning                                                  |
| --------- | ----------------- | -------------------------------------------------------- |
| `active`  | green, live       | Receiving deliveries.                                     |
| `paused`  | dimmed row        | You stopped deliveries with `webhooks.update`. Reversible. |
| `failing` | red               | The platform marked the endpoint as failing after repeated unsuccessful deliveries. |

`consecutive_failures` counts unsuccessful deliveries in a row; the
console prints it under the status pill whenever it is above zero. A
successful delivery is what clears it. The row also shows
`last_delivery_at` and `last_status_code`, which is usually enough to tell
"my endpoint is down" from "my endpoint is rejecting the signature".

Pause and resume are the same call with a different `status`:

```ts
await webhooks.update({ orgId, id, status: "paused" });
await webhooks.update({ orgId, id, status: "active" });
```

`webhooks.remove` deletes the endpoint and deliveries to it stop
immediately. Prefer pausing while you debug — a paused endpoint keeps its
subscription, its secret and its delivery history.

## The delivery log

`webhooks.deliveries.list` is scoped **server-side**. Pass
`vertical: 'realtime'` and you get realtime deliveries plus deliveries to
org-wide endpoints, already filtered. There is no client-side filtering
to do, and a busy voice or streams tenant cannot crowd realtime rows out
of the page. Pass an `endpointId` to pin the log to one endpoint; the
`vertical` filter is ignored in that case.

The list is paginated with `limit` and `page`. The console requests 50
rows per page and re-polls every 15 seconds.

List responses deliberately omit the fat fields. To see the full request
payload and `last_error` for one attempt, call
`webhooks.deliveries.get({ orgId, id })` — this is what the console's
per-delivery detail drawer does.

## Redelivery and duplicate handling

`webhooks.deliveries.redeliver({ orgId, id })` queues an existing
delivery to be sent again. It returns as soon as the delivery is queued,
not when your endpoint answers, so watch the log for the new attempt.

Use it after you have fixed the cause: redeploy the handler, rotate to a
secret both sides agree on, or resume a paused endpoint, then replay the
failed deliveries.

Because a delivery can be replayed — by you, from this screen — your
handler must be idempotent. Key on the event's identity and make a repeat
of an event you have already processed a no-op rather than a second
write.

## Testing an endpoint

`webhooks.sendTest({ orgId, id })` sends a signed `test.ping` to the
endpoint immediately. It is signed with the endpoint's current secret, so
it exercises your verification path, not just your routing.

The result tells you what happened at the HTTP level:

| Field        | Meaning                                                    |
| ------------ | ---------------------------------------------------------- |
| `ok`         | Whether the endpoint accepted the test.                    |
| `statusCode` | The HTTP status your endpoint returned, when there was one. |
| `error`      | The transport or handler error, when `ok` is false.        |

No `statusCode` at all means the request never got an HTTP response —
DNS, TLS, or connectivity — rather than a rejection by your handler.

Send a test right after creating an endpoint and again after every
secret rotation.

## Organizing endpoints with tags

Endpoint rows are taggable. Because they live in the unified
`webhook_endpoint` table, they tag under the `webhook_endpoint` resource
kind rather than a realtime-specific one, so a tag you apply here is
visible on the same endpoint from the other products' consoles.

## Related

- [Authentication](/concepts/authentication) — API keys and org scoping
- [Telemetry](/platform/telemetry) — the other operational data streams
