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

# Webhooks & Events

> The platform's async event backbone — how results are captured and how to consume them.

Voice and WhatsApp results are asynchronous. This page covers the webhooks that feed results into the platform, the event log you can read, and how to consume outcomes reliably in your product.

<Note>
  Two directions, and they are unrelated. **[Outbound](#outbound-webhooks)** is what you register: Lehar POSTs call events to your HTTPS endpoint. **Inbound** are the platform's own webhooks — the agent and messaging provider post to them — documented here because they explain the async timing and matter for bring-your-own-agent deployments.
</Note>

## Outbound webhooks

Register an HTTPS endpoint and receive call events as they happen, instead of polling.

Subscriptions are per **agent**, so different agents can deliver to different endpoints. One subscription covers that agent's API-fired [calls](/concepts/calls), [campaign](/concepts/campaigns) calls and inbound calls alike.

<Warning>
  **An agent with no subscription emits nothing.** That includes every agent you create from now on. Cloning an agent does copy its subscriptions.
</Warning>

### Register an endpoint

```bash theme={null}
curl -X POST "$LEHAR_BASE_URL/webhooks/subscriptions" \
  -H "X-API-KEY: $LEHAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agent_sales_hi",
    "url": "https://hooks.example.com/lehar",
    "events": ["call_completed"],
    "headers": { "Authorization": "Bearer your-receiver-token" },
    "description": "CRM sync"
  }'
```

| Method | Path                                  | Scope                                                                                                                                |
| ------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| POST   | `/webhooks/subscriptions`             | `api_keys:write`                                                                                                                     |
| GET    | `/webhooks/subscriptions[?agent_id=]` | `workspace:read`                                                                                                                     |
| PATCH  | `/webhooks/subscriptions/{id}`        | `api_keys:write` (`url`, `events`, `description`, `status`, `headers`, `signing_enabled`, `rotate_signing_secret`, `payload_format`) |
| DELETE | `/webhooks/subscriptions/{id}`        | `api_keys:write`                                                                                                                     |

`url` must be an absolute **https** URL, and is rejected if it points at a private or loopback address. An empty or omitted `events` array means **every** event type. A subscription is bound to its agent for life — delete and recreate it to move it.

You can also manage all of this from the dashboard: open the agent, then **Advanced → Webhooks**. That section stays editable after the agent is published, because an endpoint is operational configuration rather than agent behaviour.

Reads carry `status`, `last_delivery_at` and `last_error`, which is where to look first when deliveries stop arriving.

### Authenticate the delivery

Whatever you put in `headers` is replayed verbatim on every delivery — typically a static `Authorization: Bearer …` or `X-Webhook-Secret: …` your receiver compares against. This is the default and needs no signature verification.

Constraints, all enforced at registration so a bad value fails fast rather than silently killing deliveries: at most 20 headers and 8 KB total; names must be valid HTTP tokens; values cannot contain line breaks or control characters; and framing headers (`Content-Type`, `Host`, `Transfer-Encoding`, …) plus our own `X-Webhook-*` headers are reserved.

<Warning>
  Header **values are write-only** — no read returns them, since they hold your credentials. `GET` shows `header_names` only, and a `PATCH` of `headers` replaces the whole object rather than merging.
</Warning>

### Events

| Event                 | Fires when                                                       | Adds beyond the common core                              |
| --------------------- | ---------------------------------------------------------------- | -------------------------------------------------------- |
| `call_started`        | A dial has produced a session                                    | —                                                        |
| `call_completed`      | Terminal outcome, after billing, outcome and latency are written | `call_duration`, `transcript`, `sentiment`               |
| `recording_completed` | A [recording](/concepts/sessions) is ready                       | `recording_id`, `recording_duration`, `recording_status` |

`call_completed` fires for every kind of call. `recording_completed` fires once per recording, so you receive it only for calls that were actually recorded — and it arrives *after* `call_completed`, since a recording is finalized separately from the call. **`call_started` fires only for calls placed through the API** — campaign and inbound calls do not run through that dial path.

Enqueue is separate from send: a slow or dead endpoint can never hold up billing, outcome classification or a campaign's progress.

### Payload

Every event shares a common core and adds its own extras:

```json theme={null}
{
  "event_type": "call_completed",
  "event_id": "call_completed:call_01EXAMPLE",
  "version": "1",
  "timestamp": "2026-08-11T09:04:21Z",
  "call_id": "call_01EXAMPLE",
  "session_id": "session_01EXAMPLE",
  "call_sid": "abc-123-carrier-id",
  "agent_id": "agent_sales_hi",
  "agent_name": "Sales Agent",
  "workspace_id": "customer_01EXAMPLE",
  "status": "completed",
  "sub_status": "completed",
  "outcome": "completed",
  "call_type": "outbound",
  "metadata": { "candidate_id": "c_8f21", "source": "voice_campaign" },
  "to_number": "+9198****3210",
  "from_number": "+911140000000",
  "inbound_from": null,
  "called_on": "2026-08-11T09:02:10Z",
  "created_at": "2026-08-11T09:02:04Z",
  "call_cost": 4.12,
  "overall_latency_seconds": 1.31,
  "first_utterance_seconds": 0.84,
  "bulk_list_id": null,
  "call_duration": 131,
  "transcript": "[{\"role\":\"user\",\"text\":\"…\"}]",
  "sentiment": "positive",
  "agent_message_count": 6,
  "user_message_count": 6
}
```

A few things worth knowing before you write the receiver:

* **`metadata` is your own bag, echoed verbatim.** Whatever object you sent as `metadata` on the call comes back unchanged on every event — arbitrary keys, no schema, never interpreted. This is what you correlate an event against your own records with. `{}` for a call placed any other way.
* **`custom_args_values` is not echoed**, unlike the source contract. Those are your prompt variables, and a caller who ships whole prompt blocks through them would get tens of KB of their own text back on three events per call. Put your correlation keys in `metadata`; read the prompt variables from `GET /calls/{call_id}` if you need them.
* **`to_number` is masked.** The raw destination is never sent — an event body lands in your logs.
* **On an inbound call** `inbound_from` carries the caller and `to_number` is the number they dialled.
* **`transcript` is a JSON string**, not an array — on the `native` format. Parse it, or use `payload_format: "ringg"` to get an array of turns.
* **`bulk_list_id`** is the campaign id, and is present-but-`null` for API-fired calls, so a receiver written against the campaign shape doesn't have to branch.
* **`call_type`** is `outbound`, `inbound` or `web`.
* **`overall_latency_seconds`** is the average full turn round-trip;
  **`first_utterance_seconds`** is the average time to first token. Both are averaged
  across the call's turns, not measured on a single one.
* **Message counts are per transcript item**, so consecutive fragments from the same
  speaker each count. A source contract that merges them will report smaller numbers for
  the same conversation.

### Payload format

`payload_format` selects the wire shape. `native` (the default) is the contract above. `ringg` reshapes the body for a receiver written against [Ringg's webhooks](/guides/individual-call-compatibility): `status` and `sub_status` use Ringg's vocabulary, `recording_completed` carries a 24-hour `recording_url`, `call_completed` gains `retry_count`/`attempts`, and `transcript` becomes an array of turns:

```json theme={null}
[
  { "bot":  "Hi Kumar, is this a good time?",
    "message_id": "item_7e6b4b7e228a", "timestamp": "2026-08-11T12:30:32.402+00:00" },
  { "user": "Yes, go ahead.",
    "message_id": "item_4563b7d49793", "timestamp": "2026-08-11T12:30:35.139+00:00" }
]
```

Each turn is keyed by the speaker and carries `message_id` and an ISO `timestamp`. A key whose source is missing is omitted rather than sent as `null`.

Three differences remain in both formats: `to_number` stays masked; `version_id`, `version_slug`, `version_description` and `tool_call_logs` are not sent because Lehar has no equivalent; and turns carry no `audio_offset_ms`, because the source contract measures it from when the callee answered while our timeline starts at session creation — the same field name would mean two different things.

### Optional: verify a signature

Every delivery carries these two headers, whatever your auth setup — they are routing and dedupe aids, not authentication:

```
X-Webhook-Event: call_completed
X-Webhook-Event-Id: call_completed:call_01EXAMPLE
```

If you would rather verify a signature than a shared header, register with `"signing_enabled": true` and two more are added:

```
X-Webhook-Timestamp: 1770000000
X-Webhook-Signature: v1=<hex>
```

The signature is `HMAC_SHA256(signing_secret, "{timestamp}." + raw_body)` — the same scheme as the inbound session-update webhook below, so there is one thing to implement in each direction. Sign over the **raw** body before any JSON parsing, compare in constant time, and reject a stale timestamp: the timestamp is inside the signed material, so a captured request cannot be replayed later. On a `ringg` subscription the signature covers the reshaped bytes you actually receive.

<Warning>
  The `signing_secret` is returned **only** when it is minted — in the `201` create response, or from a `PATCH` with `"rotate_signing_secret": true` — and is stripped from every read. Reads carry a `signing_enabled` boolean instead.
</Warning>

### Delivery semantics

* **Deduplicated at the source.** `event_id` is deterministic (`{event_type}:{call_id}`) and a uniqueness constraint stops a duplicate POST when finalize re-runs. Dedupe on `event_id` anyway.
* **Retries** follow `15s → ×2 → 2min`, up to 8 attempts, but **only** for `429` and `5xx`. Any other `4xx` is treated as permanent and stops immediately.
* **Timeout** is 10 seconds. Respond `2xx` fast and do your work asynchronously.

## Session-update webhook

When a call ends, the agent posts the transcript, usage, and a session report here:

`POST {API_BASE}/webhooks/livekit/session-updates`

This is what populates a session's transcript and usage and triggers [billing](/concepts/billing) — understanding it explains the async timing, and it's the integration point for self-hosted or bring-your-own-agent deployments.

### Authentication

Accepts any one of (checked in order):

1. **HMAC** — headers `X-Webhook-Timestamp` and `X-Webhook-Signature: v1=<hex>`, where the signature is `HMAC_SHA256(secret, "{timestamp}." + raw_body)`. Timestamp tolerance is 300s by default.
2. **Bearer / API key** with `sessions:write` (tenant- and session-scoped).
3. **Media-server token**.

Only trusted auth modes (HMAC, media-server, agent-session) are allowed to supply billable usage, so a tenant API key can't forge its own charges.

### Payload

```json theme={null}
{
  "event": "agent_session_ended | session_update | room_finished",
  "id": "<event id>",
  "session_id": "session_…",
  "status": "active | ended | failed | expired",
  "transcript": [{ "role": "user", "text": "…", "item_id": "…", "created_at": "…" }],
  "transcript_delta": [],
  "usage": { "model_usage": [], "tokens": {} },
  "custom_events": [{ "ts": "…", "event": "…", "payload": {} }],
  "session_report": { "livekit_session_report_v1": {} }
}
```

The target session is resolved by `session_id`, then `metadata.session_id`, then `room_name`. The response is `{ "ok": true, "session_id": "…", "status": "…", "metadata": {…}, "updated_at": "…" }`, or `404` if the session isn't found for the authenticated tenant.

### Side effects

* **Terminal statuses** (`ended`, `failed`, `expired`) meter usage into billing. Redelivery is idempotent by usage hash; corrected usage is reversed and re-charged, never added on top.
* **Campaign sessions** signal the recipient workflow that the attempt completed, unblocking the [campaign run](/guides/campaign-lifecycle).

## Recording (egress) events

When call recording is enabled, Recording Service lifecycle events (`egress_started`, `egress_updated`, `egress_ended`) are delivered to `/webhooks/livekit/egress` and finalize the matching recording row. Recordings are then retrievable via the recordings endpoints and a short-lived playback URL.

## WhatsApp inbound

`POST {API_BASE}/webhooks/whatsapp/inbound` (provider-authenticated) receives replies and delivery receipts. It is **idempotent** on the provider message id and applies delivery statuses **monotonically**. An inbound reply opens the 24-hour service window and wakes the waiting [WhatsApp recipient](/guides/whatsapp).

## Campaign event log

Every campaign transition is written to an append-only, readable event log — the closest thing to an event stream you can consume today:

`GET /campaigns/{id}/events` (scope `campaigns:read`, optional `recipient_id` filter)

Observed `event_type` values include:

* **Voice:** `campaign.workflow_bootstrapped`, `campaign.recipient_attempt_scheduled`, `campaign.voice_attempt_started`, `campaign.voice_attempt_<outcome>`, `campaign.recipient_finalized`, `campaign.completed`.
* **WhatsApp:** `campaign.whatsapp_template_sent`, `…_reminder_sent`, `…_reply_sent`, `…_opt_out_detected`, and the structured `campaign.whatsapp_decision`.

Each entry carries `{ event_type, event_json, recipient_id?, attempt_id?, created_at }`. This is an audit trail, not an external bus.

## Consuming results by polling

[Outbound webhooks](#outbound-webhooks) are the recommended path. Polling remains available, and is the fallback when your receiver is down:

* **An API-fired call** — poll `GET /calls/{id}` until `status` is terminal; its `attempts` list the sessions.
* **A single session** — poll `GET /sessions/{id}` until `status` is terminal, then read the transcript and usage.
* **A campaign** — poll `GET /campaigns/{id}/attempts` and `GET /campaigns/{id}/events` for per-recipient outcomes.
* **WhatsApp** — read `GET /campaigns/{id}/messages` and the `whatsapp_decision` events.

<Tip>
  Whether you poll or receive, make it **idempotent** — dedupe on `event_id` (or `session_id` plus event type, or the provider message id) and treat only terminal statuses as final. The platform's own webhook handling follows the same rule.
</Tip>
