Skip to main content
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.
Two directions, and they are unrelated. Outbound 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.

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, campaign calls and inbound calls alike.
An agent with no subscription emits nothing. That includes every agent you create from now on. Cloning an agent does copy its subscriptions.

Register an endpoint

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.
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.

Events

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:
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: 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:
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:
If you would rather verify a signature than a shared header, register with "signing_enabled": true and two more are added:
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.
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.

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 — 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

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.

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.

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 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.
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.