> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hubtalk.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> The `call_ended` and `call_analyzed` events, signing, retries and delivery order.

# Webhooks

Webhooks are the primary channel for results; polling `GET /v1/calls/{call_id}` is the fallback. An administrator registers the endpoint **on the agent**: open the agent in the editor → **Settings** tab → section **Webhooks (post-call)**. The payload contents are also described on the [Webhooks](/v4/webhooks) page.

* **URL** — `http://` or `https://` (the platform does not enforce HTTPS; use it anyway). Delivery is subject to the platform's outbound host policy: private addresses and disallowed hosts may be refused by the operator's configuration, and such deliveries fail into the dead-letter queue.
* **Event subscription** — any subset of the three events below (default: `call_ended` + `call_analyzed`).
* **Signing secret** — configured on the platform side as a **reference to a server-side secret** (the name of an environment variable of the platform deployment); the value itself is never entered in the dashboard and is shared with you out of band.
* **Agent** — an endpoint is bound to exactly one live agent of the organization. The field is mandatory: an empty agent name is refused with `webhooks.agent_required`, an unknown or archived one with `webhooks.agent_not_found`. There is no organization-wide level — to cover several agents, register the endpoint on each of them (the same URL may be reused; `agent_id` in the payload tells them apart).
* **Test events** — an opt-in flag makes the endpoint also receive events of editor test sessions (`mode: "test"`).
* **Timeout** — how long the platform waits for your `2xx` (platform default 10 s, configurable per endpoint up to 120 s; `0` means the platform default).

### Event envelope

Every event is an HTTP(S) `POST` with a JSON body (UTF-8, non-ASCII characters unescaped).

| Field                             | Description                                                                                                        |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `event`                           | `call_ended` \| `call_analyzed` \| `observer_incident` (plus `ping` for test deliveries)                           |
| `event_id`                        | Unique event ID (`evt-` + 32 hex). Identical across delivery retries and across endpoints — **deduplicate on it**. |
| `timestamp`                       | When the event was built, epoch seconds.                                                                           |
| `call_id`                         | Correlates with the call object.                                                                                   |
| `agent_id`                        | Agent name.                                                                                                        |
| `flow_version`, `flow_version_id` | Flow version marker (same type caveat as in the call object) and published version ID.                             |
| `metadata`                        | Your payload from call creation — present in **every** event.                                                      |

Headers on every delivery:

```
Content-Type: application/json
User-Agent: conversation-flow-webhooks/1.0
X-CFlow-Event: call_ended
X-CFlow-Event-Id: evt-4f0c22b17a9d4e3c8b6a5f019e2d7c31
X-CFlow-Timestamp: 1753344187
X-CFlow-Signature: v1=6f2a45c1e8…
```

### Which event arrives when

* **`call_ended`** — as soon as the session finishes, before analysis and usually before the recording is finalized. This is the early notification: transcript, `call_status`, `status_reason`, `error_code`, and the timing fields (`answered_at`, `media_started_at`, `talk_from`, `talk_to`). `latency` is present only when per-turn metrics were collected; its keys are the raw metric names ending in `_ms` (`turn_ms`, `llm_ms`, `asr_ms`, `tts_ms`, …) with values `{avg, max}` in milliseconds.
* **`call_analyzed`** — when **both** post-call pipelines are done: the analysis (terminal in any of `done`, `skipped`, `failed`, `interrupted` — the event is always delivered) and the recording finalization. The event is **self-contained**: full call block + analysis + ready recording. If you only need the final outcome, subscribe to `call_analyzed` alone and skip `call_ended`. Its `call` block is identical to `call_ended.call` except that the recording stays top-level and is not duplicated inside `call`.
* **`observer_incident`** — **during** the call, when a compliance observer configured on the agent detects a violation and its reaction includes a notification. Use it for real-time supervisor alerts. `severity` is `low` | `medium` | `high` (an unknown value is normalized to `medium`), `confidence` is 0–1 or `null`, `mode` may be `""` when unknown. Multiple incidents in one call produce separate events; retries of the same incident keep the same `event_id`.
* **`ping`** — a test delivery from the dashboard button; `agent_id` is the agent the endpoint is bound to (`""` for an organization-level endpoint). Respond `2xx` and verify its signature like a real event. It is not retried.

When `analysis_status` is anything but `done`, `call_analysis` is `{}` and a top-level `reason` field explains why (for example `voicemail`, `no_speech`, `analytics_disabled`); `analytics_tier`, `analytics_model_used` and `analytics_language` are empty strings then.

### Delivery semantics

* **At-least-once.** A delivery counts as successful only on a `2xx` from your endpoint; anything else (non-`2xx`, timeout, connection error) is retried. You **will** receive duplicates — deduplicate on `event_id`.
* **Retries with exponential backoff** — platform settings, not per endpoint: by default up to 8 attempts with pauses of 30, 60, 120, 240, 480, 960 and 1920 seconds. After the final failed attempt the event moves to a dead-letter queue, from which an administrator can redeliver it manually (History → session details → webhook deliveries). Dead deliveries are purged after a retention period, so the manual-retry window is finite.
* **Deliveries always go to the endpoint's current URL**, so a mistyped URL can be corrected and the event redelivered; an endpoint that was deleted or disabled sends its pending events straight to the dead-letter queue.
* **Ordering is best-effort.** `call_ended` is enqueued before `call_analyzed`, but delivery order is not guaranteed; `observer_incident` arrives mid-call. Treat each event independently, correlating by `call_id`.
* **Respond fast.** Return `2xx` immediately after signature verification and process asynchronously: the platform waits at most the endpoint's timeout.

### Signature verification

```
signed_payload = "{X-CFlow-Timestamp}." + raw request body
X-CFlow-Signature: v1=<hex( HMAC-SHA256(secret, signed_payload) )>
```

`X-CFlow-Timestamp` is an integer number of Unix seconds. Verify on the **raw bytes** of the body (before any JSON parsing or re-serialization — the body is UTF-8 with unescaped non-ASCII characters), compare in constant time, and reject stale timestamps — a 300-second window. Retried deliveries are re-signed with a fresh timestamp, so the window does not conflict with retries.

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify_webhook(
      secret: str,
      signature_header: str,   # X-CFlow-Signature, e.g. "v1=6f2a45…"
      timestamp_header: str,   # X-CFlow-Timestamp, e.g. "1753344187"
      raw_body: bytes,         # exact request body bytes
      tolerance_seconds: int = 300,
  ) -> bool:
      try:
          ts = int(timestamp_header)
      except (TypeError, ValueError):
          return False
      if abs(time.time() - ts) > tolerance_seconds:
          return False  # replay protection
      signed = f"{ts}.".encode("utf-8") + raw_body
      expected = "v1=" + hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature_header or "")
  ```

  ```js Node.js theme={null}
  const crypto = require("crypto");

  function verifyWebhook(secret, signatureHeader, timestampHeader, rawBody, toleranceSeconds = 300) {
    const ts = parseInt(timestampHeader, 10);
    if (!Number.isFinite(ts)) return false;
    if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;
    const expected =
      "v1=" +
      crypto
        .createHmac("sha256", secret)
        .update(`${ts}.`)
        .update(rawBody) // Buffer with the exact request bytes
        .digest("hex");
    const provided = Buffer.from(signatureHeader || "");
    const wanted = Buffer.from(expected);
    return provided.length === wanted.length && crypto.timingSafeEqual(provided, wanted);
  }
  ```
</CodeGroup>

In Python (Flask) use `request.get_data()`; in Node (Express) use `express.raw({ type: "application/json" })` — both preserve the exact bytes the signature was computed over.

### Recordings

`recording_url` is a **stable** URL (`https://{{HUBTALK_FQDN}}/api/recordings/rec-…`): it does not expire and can be persisted in your systems. Each `GET` to it answers `307` with a fresh short-lived download link, so a URL saved months ago keeps working — until the recording itself is deleted by the retention policy (`recording.expires_at`). Other answers: `409` while the recording is not ready yet (keep polling), `410` after deletion, `503` when the storage is temporarily unavailable. Append `?download=1` for an attachment disposition. The URL is unauthenticated but unguessable; treat it as a capability token.

One edge case: if the recording takes unusually long to finalize (longer than the platform's wait, 30 s by default), `call_analyzed` is still emitted — with the current non-terminal recording status and an empty `recording_url`. The stable URL starts working on its own once the recording is ready.
