# Webhooks

> Notify your backend when a call is placed, answered, transferred, or ends — signed event POSTs plus the real-time event bus.

Your own systems — a CRM, a billing job, a compliance archive — often must
react the moment a call changes state. Examples: a new call came in, the call
was answered, the call transferred to a human, or the call hung up with a given
cause. Voice events exist for this purpose. The engine emits one event for
every lifecycle transition of a call, keyed by
[`call_sid`](/modalities/voice/api/calls). You consume the events in one of two
ways: as **signed HTTP POSTs to an endpoint that you register**, or off the
**real-time event bus** that your app already subscribes to.

> **NOTE:**
> The **events** on this page are shipped and flow on every call today. They are
> the same records that power
> [reports and dashboards](/modalities/voice/observability/overview).
> Programmable per-endpoint **HTTP webhooks** for voice ride the same
> signed-delivery system that powers FirstByte Streams. The signing, retry,
> and delivery log below are that system. Voice **event-type registration** is
> rolling out on that shared delivery plane. Check in the console which `call.*`
> types your workspace can subscribe to before you depend on push delivery. Use
> the real-time bus or the observability pipeline in the meantime.

## What you can subscribe to

Every event is named `call.<state>`. One call produces a stream of these events
from `initiated` through `cleared`.

| Event | Fires when | Carries |
| --- | --- | --- |
| `call.initiated` | An inbound INVITE arrives, or you originate a call | `ani`, `dnis`, `direction`, `trunk_id` |
| `call.delivered` | The call reached the destination (alerting / early media) | — |
| `call.established` | The call was answered (two-way media up) | `handler_id` when an AI/agent handles the call |
| `call.held` / `call.retrieved` | The call was put on hold / taken off hold | — |
| `call.reinvited` | Media was renegotiated mid-call (re-INVITE) | — |
| `call.transferred` | A `REFER` transfer occurred | — |
| `call.dtmf` | A DTMF digit was detected on the leg | digit in `cause` |
| `call.failed` | Setup failed before answer | `cause` (Q.850) |
| `call.cleared` | The call ended | `cause` (Q.850), `duration_sec` |

> **TIP:**
> An AI agent or a human agent handled each segment that carries a `handler_id`.
> Downstream reporting uses this field to split a call into its AI leg and its
> human leg. Use it to attribute events to the agent that owned that stretch of
> the call.

Subscribe to a set of exact names, or use `*` to receive everything. A prefix
glob such as `call.*` matches every call event.

## Event payload

Every event is a flat JSON object. Only the fields that the engine actually
knows for that transition are present. Absent fields are omitted, not null.

- **`call_sid`** (`string`, required) — The universal call identifier. Correlate every event, the CDR, recordings,   and the [audio bridge](/modalities/voice/api/transport) through this one key.
- **`event`** (`string`, required) — The lifecycle state, e.g. `established` or `cleared`. The `call.` prefix is   on the event name; the payload does not repeat it.
- **`tenant_id`** (`string`, required) — The workspace that owns the call.
- **`timestamp_ms`** (`integer`, required) — The event time in milliseconds since the Unix epoch. Use it to order events   and to reject stale re-deliveries.
- **`cause`** (`integer`) — The Q.850 clearing cause on `cleared`/`failed` (e.g. `16` = normal clearing).   On `call.dtmf` this field carries the detected digit.
- **`duration_sec`** (`integer`) — The billable talk duration in seconds. Present on `cleared`.
- **`ani`** (`string`) — The calling number (the "from").
- **`dnis`** (`string`) — The dialed number (the "to").
- **`direction`** (`string`) — `inbound` or `outbound`.
- **`trunk_id`** (`string`) — The SIP trunk that the call matched or used.
- **`handler_id`** (`string`) — The agent that owns this segment, when the engine knows it (AI or outbound).

**call.established:**
```json
{
  "call_sid": "cs_9f3ab1c27d",
  "event": "established",
  "tenant_id": "org_123",
  "timestamp_ms": 1751990400123,
  "ani": "+14155550101",
  "dnis": "+18885550199",
  "direction": "inbound",
  "trunk_id": "trunk_pstn_a",
  "handler_id": "agent_sales_ai"
}
```

**call.cleared:**
```json
{
  "call_sid": "cs_9f3ab1c27d",
  "event": "cleared",
  "tenant_id": "org_123",
  "timestamp_ms": 1751990461984,
  "cause": 16,
  "duration_sec": 58,
  "ani": "+14155550101",
  "dnis": "+18885550199",
  "direction": "inbound"
}
```

Over HTTP, this object is the raw POST body. Over the real-time bus, the same
object arrives as the `data` field of a `call.<state>` message. The message
channel has a workspace scope.

## Register an endpoint

You set up endpoints in the console, on the same **Webhooks** screen that all
products use. Each endpoint gets its own HMAC signing secret.

  1. **Add an endpoint**
Open **Webhooks** in the Contact Center console (`agent.firstbyte.fast`)
    or the Voice AI console. Click
    **Add endpoint**. Enter the HTTPS **URL** plus the **event types** to
    receive. The event types are a comma-separated list, where `*` matches
    everything (e.g. `call.*`).
  2. **Store the signing secret**
On create, the console shows the per-endpoint **signing secret once**. The
    secret looks like `whsec_…`. Copy it and set it on your receiver. It never
    appears again.

    > **WARNING:**
> The console shows the secret only at creation. You cannot retrieve it
>       later. If you lose the secret, delete the endpoint and add it again to
>       mint a new one.
  3. **Pause, resume, or delete**
Each row shows a health state: **Healthy**, **Paused**, or **Failing (Nx)**
    after N consecutive non-2xx responses. Pause to stop deliveries without
    loss of the endpoint. Delete to remove the endpoint (its delivery history
    stays in the logs).

## Verify the signature

Every delivery is signed. The engine's delivery worker computes
`HMAC-SHA256(secret, rawBody)` and sends it as a hex digest in a signature
header. Recompute the digest over the **exact bytes** that you received, before
any JSON parse. Compare the digests in constant time. Reject any delivery that
does not match. Also reject payloads whose `timestamp_ms` is far outside your
clock tolerance, to blunt replays.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the exact request body bytes; header: the signature header value.
export function verify(rawBody: Buffer, header: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express example — capture the RAW body, do NOT let a JSON parser touch it first.
app.post("/hooks/voice", express.raw({ type: "application/json" }), (req, res) => {
  const sig = String(req.header("x-webhook-signature") ?? "");
  if (!verify(req.body, sig, process.env.VOICE_WEBHOOK_SECRET!)) {
    return res.status(401).end();          // bad signature → reject
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // ... react to event.event / event.call_sid, then:
  res.status(200).end();                   // 2xx ACKs; anything else retries
});
```

```python
import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

# FastAPI example — read the RAW body before parsing.
@app.post("/hooks/voice")
async def hook(request: Request):
    raw = await request.body()
    sig = request.headers.get("x-webhook-signature", "")
    if not verify(raw, sig, os.environ["VOICE_WEBHOOK_SECRET"]):
        raise HTTPException(status_code=401)
    event = json.loads(raw)
    # ... react to event["event"] / event["call_sid"]
    return Response(status_code=200)
```

> **WARNING:**
> Verify against the raw request bytes. If a framework parses and re-serializes
> the JSON before you hash it, the key order and whitespace change. Every
> signature then fails. Capture the body raw on this route.

## Delivery, retry, and inspection

- **Acknowledge with 2xx.** Any `2xx` response marks the delivery successful.
  The worker treats any other status (or a timeout / connection error) as a
  failure and retries with backoff.
- **Retries.** The delivery worker re-fires failed deliveries automatically,
  with increasing spacing. After a run of consecutive failures, the endpoint
  flips to **Failing (Nx)**. Sustained failure pauses the endpoint, so a dead
  receiver cannot back up the queue.
- **Delivery log.** The console keeps a per-delivery record: event type, target
  object, endpoint, response code, a short response-body preview, attempt
  number, and timestamp. You can filter the log by
  **All / Delivered / Failed**.
- **Resend.** You can re-queue any delivery row from the log with **Resend**.
  Resend re-fires the same event within seconds. Use it after you fix a
  receiver that returned errors.

> **NOTE:**
> Make your handler **idempotent**. Because of retries and manual resends, the
> same `(call_sid, event, timestamp_ms)` can arrive more than once. Dedupe on
> that tuple. Do not assume exactly-once delivery.

## Consume events without an endpoint

You do not have to run a public HTTPS receiver. The same events are available
in two other places today:

  - **[Real-time event bus](/modalities/voice/api/agents)** — `call.<state>` messages publish live on a workspace-scoped channel — the
    same bus that the agent control channel and consoles subscribe to. Best to
    drive a UI in real time.
  - **[Observability pipeline](/modalities/voice/observability/overview)** — The pipeline folds every lifecycle transition per `call_sid` into the
    durable reporting store behind dashboards and CDRs. Best for
    after-the-fact reporting and reconciliation.

## Related

  - **[Calls API](/modalities/voice/api/calls)** — Originate, look up, transfer, and hang up — the control plane for the
    `call_sid` that these events key off.
  - **[Call traces](/modalities/voice/observability/call-traces)** — Follow one call end-to-end across signalling, media, and the agent runtime.
  - **[Agent control channel](/modalities/voice/api/agents)** — The event surface for human-agent softphones: offers, state changes, accept
    and reject.
  - **[Errors](/modalities/voice/api/errors)** — The status codes and failure causes that you will see on `call.failed` and
    `call.cleared`.
