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.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 inheaders 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.
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:metadatais your own bag, echoed verbatim. Whatever object you sent asmetadataon 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_valuesis 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 inmetadata; read the prompt variables fromGET /calls/{call_id}if you need them.to_numberis masked. The raw destination is never sent — an event body lands in your logs.- On an inbound call
inbound_fromcarries the caller andto_numberis the number they dialled. transcriptis a JSON string, not an array — on thenativeformat. Parse it, or usepayload_format: "ringg"to get an array of turns.bulk_list_idis the campaign id, and is present-but-nullfor API-fired calls, so a receiver written against the campaign shape doesn’t have to branch.call_typeisoutbound,inboundorweb.overall_latency_secondsis the average full turn round-trip;first_utterance_secondsis 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:
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:"signing_enabled": true and two more are added:
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.
Delivery semantics
- Deduplicated at the source.
event_idis deterministic ({event_type}:{call_id}) and a uniqueness constraint stops a duplicate POST when finalize re-runs. Dedupe onevent_idanyway. - Retries follow
15s → ×2 → 2min, up to 8 attempts, but only for429and5xx. Any other4xxis treated as permanent and stops immediately. - Timeout is 10 seconds. Respond
2xxfast 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):- HMAC — headers
X-Webhook-TimestampandX-Webhook-Signature: v1=<hex>, where the signature isHMAC_SHA256(secret, "{timestamp}." + raw_body). Timestamp tolerance is 300s by default. - Bearer / API key with
sessions:write(tenant- and session-scoped). - Media-server token.
Payload
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 structuredcampaign.whatsapp_decision.
{ 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}untilstatusis terminal; itsattemptslist the sessions. - A single session — poll
GET /sessions/{id}untilstatusis terminal, then read the transcript and usage. - A campaign — poll
GET /campaigns/{id}/attemptsandGET /campaigns/{id}/eventsfor per-recipient outcomes. - WhatsApp — read
GET /campaigns/{id}/messagesand thewhatsapp_decisionevents.

