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.

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: 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: Verify before you trust the payload:
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. 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:
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: 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.