# Webhooks

> One outbound-webhook system for every modality: register an endpoint, verify the signature, and read the delivery log when something does not arrive.

Every modality emits events through the **same** webhook system. Voice, chat,
streams, realtime, tunnel, QuickDesk, VPN, teleop, crypto, robotics, inference,
compute and SMS all register endpoints the same way, sign the same way, and
retry the same way. Learn it once.

You register an endpoint in the console of the product you want events from.
Each console's **Webhooks** screen manages endpoints for its own vertical.

> **NOTE:**
> This page is the platform mechanism — signing, retries, the delivery log. For
> the voice event catalogue specifically (what `voice.call.*` means and when each
> fires), see [Voice webhooks](/modalities/voice/api/webhooks).

## Choosing event types

Every event type is a `<vertical>.<subject>.<action>` token, and the set of
valid tokens is a server-side registry. The picker in each console renders
**from that registry**, so what you can select is exactly what something can
actually send.

You can subscribe to `*` for everything in the vertical, or use dot-bounded
globs such as `voice.call.*`.

> **WARNING:**
> An unknown token is rejected when you save, not silently stored. That is
> deliberate: a typo used to be accepted and then simply never matched, which
> looks identical to "the event never fired". If you get `unknown event type`,
> the token does not exist — check the picker rather than guessing.

## Verifying the signature

Every delivery carries three headers:

```http
X-Clutchcall-Event:     voice.call.ended
X-Clutchcall-Delivery:  <delivery uuid>
X-Clutchcall-Signature: t=<epoch-seconds>,v1=<hex hmac-sha256>
```

> **NOTE:**
> These header names are **literal**, on every brand. They are not brand-swapped,
> so do not substitute your own brand name — match on `X-Clutchcall-Signature`
> exactly or your verification will never find the header.

The signature is Stripe-style: the timestamp is bound into the MAC, so a
captured request cannot be replayed later with a fresh timestamp.

```
v1 = hex( hmac_sha256( signing_secret, `${t}.${raw_request_body}` ) )
```

Two rules decide whether your verification works:

1. **HMAC the raw bytes.** Parse the JSON *after* verifying. If you decode and
   re-serialize first, key order and spacing change and the MAC will not match.
2. **Compare in constant time**, and reject a timestamp that is too far from
   now — that check is what makes the replay binding useful.

**Node:**
```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

// `raw` MUST be the unparsed body (e.g. express.raw({ type: 'application/json' })).
export function verify(raw: Buffer, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=') as [string, string]),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac('sha256', secret).update(`${t}.${raw}`).digest();
  const got = Buffer.from(parts.v1 ?? '', 'hex');
  return got.length === expected.length && timingSafeEqual(got, expected);
}
```

**Python:**
```python
import hmac, hashlib, time

def verify(raw: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > 300:
        return False

    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

The signing secret is `whsec_…`. It is minted server-side, stored sealed, and
shown **in cleartext exactly once** — when you create the endpoint or rotate
the secret. There is no way to read it back afterwards; if you lose it, rotate.

> **TIP:**
> Realtime endpoints additionally receive `X-Pusher-Signature`, an HMAC over the
> body alone, so an existing Pusher webhook consumer keeps working unchanged.
> There is no `X-Pusher-Key` — a unified endpoint is not bound to a single app
> key.

## Retries

A delivery is attempted up to **6 times**. The schedule, with ±20% jitter:

| Attempt | Waits |
| --- | --- |
| 2nd | 30 seconds |
| 3rd | 2 minutes |
| 4th | 10 minutes |
| 5th | 1 hour |
| 6th | 4 hours |
| (final) | 12 hours |

Return **2xx** to acknowledge. Anything else — including a timeout, and the
request timeout is 10 seconds — counts as a failure and schedules a retry.

After **20 consecutive failures** the endpoint auto-pauses and its status
becomes `failing`. That is a circuit breaker, not a deletion: nothing is
retried against a dead endpoint until you resume it from the console. Fix the
receiver, then resume.

Because retries exist, **your handler must be idempotent.** Deduplicate on
`X-Clutchcall-Delivery`, which is stable across attempts of the same delivery.

## When something does not arrive

Each console's Webhooks screen has a delivery log showing status, attempt
count, the last response code, the last error, and when the next attempt is
due. Use it before assuming an event never fired — the common answers are that
the endpoint returned a non-2xx, or that it auto-paused after a run of
failures.

You can **redeliver** any past delivery from that log, and **send a test
event** to an endpoint without waiting for real traffic.

## Endpoint URLs we refuse

A webhook URL must be `http(s)` and must resolve to a routable address.
Link-local, cloud metadata and loopback addresses are refused unconditionally;
private RFC1918 ranges are refused unless the deployment is on-prem and enables
it.

The check runs twice — when you save, so you see the error in the form, and
again at send time, so a hostname repointed after creation still cannot reach
inward.

## Related

- [Voice webhooks](/modalities/voice/api/webhooks) — the voice event catalogue
- [Telemetry](/platform/telemetry) — for metrics rather than events
