Webhooks and events
Liveleg sends a signed webhook for each step of a call, from the first ring to the transcript. Add an endpoint, choose the event types, and verify each delivery with your endpoint secret.
Add an endpoint
Each account and subaccount has its own endpoints. Leave out events to receive every type. The response includes the signing secret. It is shown once.
POST/v1/webhook_endpoints
curl https://api.liveleg.dev/v1/webhook_endpoints \
-H "Authorization: Bearer $LIVELEG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/hooks/liveleg",
"events": [
"call.bridged",
"call.completed",
"amd.completed",
"summary.ready"
]
}'const res = await fetch('https://api.liveleg.dev/v1/webhook_endpoints', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LIVELEG_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://app.example.com/hooks/liveleg',
events: [
'call.bridged',
'call.completed',
'amd.completed',
'summary.ready',
],
}),
});
const endpoint = await res.json();import os, requests
endpoint = requests.post(
"https://api.liveleg.dev/v1/webhook_endpoints",
headers={"Authorization": f"Bearer {os.environ['LIVELEG_API_KEY']}"},
json={
"url": "https://app.example.com/hooks/liveleg",
"events": [
"call.bridged",
"call.completed",
"amd.completed",
"summary.ready",
],
},
).json() {
"id": "whe_7Kc02",
"url": "https://app.example.com/hooks/liveleg",
"events": [
"call.bridged",
"call.completed",
"amd.completed",
"summary.ready"
],
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
"status": "enabled"
} List endpoints with GET /v1/webhook_endpoints, change the URL or event types with PATCH /v1/webhook_endpoints/{id}, and remove one with DELETE. Set status to disabled to stop deliveries for a while. Events during that time are not sent automatically. They stay in the log for 30 days and can be resent.
Delivery
Every event has the same envelope. data holds the fields listed in the reference below plus the current state of the object. Test keys send events with livemode false.
{
"id": "evt_2Wc81Lk0",
"type": "call.bridged",
"created_at": "2026-11-03T15:42:07.412Z",
"livemode": true,
"account_id": "acc_ridgeline",
"data": {
"call_id": "cal_8Jq2mT4x",
"dial_session_id": "dls_31Ka9",
"agent_id": "agt_114",
"speech_ended_to_bridge_ms": 980,
"legs_canceled": 2
}
} - Delivery is at least once. The same event can arrive more than once. Store
webhook-idand skip ids you have already handled. - Events can arrive out of order. Sort by
created_at, or read the state indata, which is always current. - Return any 2xx within 15 seconds.
Verify signatures
Deliveries follow the Standard Webhooks spec. Each request carries three headers. The signature is an HMAC-SHA256 of webhook-id.webhook-timestamp.body, keyed with the base64 part of your secret and encoded in base64. Check it against the raw request body before you parse it, and reject timestamps more than 5 minutes old.
POST /hooks/liveleg HTTP/1.1
Content-Type: application/json
webhook-id: msg_2Wc81Lk0
webhook-timestamp: 1793720527
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= import crypto from 'node:crypto';
import express from 'express';
const app = express();
// whsec_... from the endpoint. The part after the prefix is base64.
const secret = Buffer.from(process.env.LIVELEG_WEBHOOK_SECRET.split('_')[1], 'base64');
app.post('/hooks/liveleg', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.header('webhook-id');
const timestamp = req.header('webhook-timestamp');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac('sha256', secret)
.update(`${id}.${timestamp}.${req.body}`)
.digest('base64');
const valid = req.header('webhook-signature').split(' ').some((s) => {
const sig = Buffer.from(s.split(',')[1] ?? '');
return sig.length === expected.length && crypto.timingSafeEqual(sig, Buffer.from(expected));
});
if (!valid) return res.sendStatus(400);
const event = JSON.parse(req.body);
queue.add(event.type, event, { jobId: id }); // webhook-id is the same on every retry
res.sendStatus(204);
});import base64, hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
# whsec_... from the endpoint. The part after the prefix is base64.
secret = base64.b64decode(os.environ["LIVELEG_WEBHOOK_SECRET"].split("_", 1)[1])
@app.post("/hooks/liveleg")
def liveleg_hook():
msg_id = request.headers["webhook-id"]
timestamp = request.headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return "", 400
signed = f"{msg_id}.{timestamp}.{request.get_data(as_text=True)}".encode()
expected = base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest()).decode()
sigs = [s.split(",", 1)[-1] for s in request.headers["webhook-signature"].split()]
if not any(hmac.compare_digest(s, expected) for s in sigs):
return "", 400
enqueue(request.get_json(), dedupe_key=msg_id) # same id on every retry
return "", 204 Any Standard Webhooks library also works, for example standardwebhooks on npm and PyPI.
Rotate a secret at any time. The response holds the new secret. The old secret stays valid for 24 hours by default. Set expire_old_in_hours from 0 to 168 to change that. Until it expires, each delivery carries two signatures in webhook-signature, one per secret, and a match on either one is valid. The code above checks every signature in the header.
curl -X POST https://api.liveleg.dev/v1/webhook_endpoints/whe_7Kc02/rotate_secret \
-H "Authorization: Bearer $LIVELEG_API_KEY" Handle each event once
A retry carries the same webhook-id as the first attempt. Record the id before you act, in the same database as the work it triggers, and skip ids you have seen. Return 2xx right away and do slow work in a queue. A handler that waits on your CRM can pass 15 seconds and the delivery is retried.
// webhook_events has a unique index on webhook_id.
async function handleDelivery(webhookId, event) {
const { rowCount } = await db.query(
'INSERT INTO webhook_events (webhook_id, type, created_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
[webhookId, event.type, event.created_at],
);
if (rowCount === 0) return; // already handled
if (event.type === 'call.completed') {
await crm.logCall(event.data.call_id, event.data.billed_seconds, event.data.disposition);
}
}# webhook_events has a unique index on webhook_id.
def handle_delivery(webhook_id, event):
cur = db.execute(
"INSERT INTO webhook_events (webhook_id, type, created_at) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
(webhook_id, event["type"], event["created_at"]),
)
if cur.rowcount == 0:
return # already handled
if event["type"] == "call.completed":
crm.log_call(event["data"]["call_id"], event["data"]["billed_seconds"], event["data"]["disposition"]) - For state such as a call's status, use the state in
dataandcreated_at. Ignore an event older than the one you already stored for that object. - Keep handled ids for at least 4 days, longer than the retry schedule.
Retries
A delivery that times out or gets a response other than 2xx is retried on this schedule, counted from the previous attempt:
- 5 seconds
- 5 minutes
- 30 minutes
- 2 hours
- 5 hours
- 10 hours
- Every 12 hours after that, until about 3 days have passed
Delivery logs are kept for 30 days with each attempt, its response code and timing. Resend any event from the log or the API. A resend uses the same webhook-id.
curl -X POST https://api.liveleg.dev/v1/events/evt_2Wc81Lk0/resend \
-H "Authorization: Bearer $LIVELEG_API_KEY" Event reference
All 67 event types, with the key fields in data.
Dial sessions
| Event | Sent when | Key fields |
|---|---|---|
dial_session.started | A session starts for an agent. | dial_session_id, agent_id, lead_list_id, lines |
dial_session.round_started | A new set of legs is placed for the agent. | dial_session_id, lines, lead_ids |
dial_session.paused | The session is paused by your app, or because the agent disconnected. | dial_session_id, reason (api, agent_disconnected) |
dial_session.resumed | A paused session starts dialing again. | dial_session_id |
dial_session.lines_changed | With lines set to auto, the number of lines per agent goes up or down. | dial_session_id, previous, current, reason (answer_rate, abandonment_rate, cap_reached) |
dial_session.stopped | The session ends: stopped, idle for 15 minutes, list empty or calling hours closed. | dial_session_id, reason, totals |
Lead lists and campaigns
| Event | Sent when | Key fields |
|---|---|---|
lead.status_changed | A lead moves to a new status, for example from queued to scheduled or exhausted. | lead_id, lead_list_id, previous, status, reason, next_call_at |
lead_list.emptied | A lead list has no lead left that can be dialed now. | lead_list_id, campaign_id, counts |
compliance.cap_cleared | A campaign's 30-day abandoned-call rate is back under the cap and normal line counts return. | campaign_id, rate_30d, cap |
Agents
| Event | Sent when | Key fields |
|---|---|---|
agent.connected | An agent connects from the browser, a mobile app or a SIP phone. | agent_id, via (browser, ios, android, sip), user_agent |
agent.disconnected | An agent's connection closes. Their dial session pauses. | agent_id, via, reason (disconnect, network, token_expired, replaced) |
Legs and calls
| Event | Sent when | Key fields |
|---|---|---|
leg.ringing | A leg starts ringing. | leg_id, call_id, to, from |
leg.answered | A leg is answered, before detection decides. | leg_id, call_id, answered_at |
leg.canceled | A leg is canceled because another leg was bridged. | leg_id, rang_ms |
leg.no_answer | A leg rings for the full ring time and nobody answers. The leg result is no_answer. | leg_id, call_id, rang_ms |
leg.failed | The carrier rejects the leg: busy, invalid number, network error. | leg_id, cause, sip_code |
amd.completed | Detection decides on an answered leg. | call_id, result, speech_ended_to_bridge_ms, action |
call.bridged | A live person is connected to an agent. | call_id, agent_id, speech_ended_to_bridge_ms, legs_canceled, ivr_path, hold_seconds |
call.abandoned | A live person answered and no agent was free. | call_id, message_played, campaign_rate_30d |
call.completed | A bridged call ends. | call_id, billed_seconds, hangup_by, disposition |
call.blocked | A pre-dial check stopped a call before it was placed. | lead_id, phone, state, dial_session_id, campaign_id, reason (state_unknown, line_disconnected, internal_dnc, national_dnc, state_dnc, outside_calling_hours, state_call_limit, no_consent), rule, calls_in_period, next_allowed_at |
inbound.routed | A call to one of your numbers is routed to an agent or voicemail. | call_id, from, to, number_state, lead_id, routed_to, reason (last_agent_spoke_to_lead, agent_pool, no_lead_match, voicemail_no_agent, opted_out), hold_seconds |
call.transferred | A transfer or forward changes state: the target joined, the transfer completed, nobody answered, or it was canceled. | call_id, mode (warm, cold, forward), from_agent_id, to, result (joined, completed, no_answer, canceled) |
call.updated | A live call is put on hold, taken off hold, muted or unmuted. | call_id, action, on_hold, muted, by (api, agent) |
call.participant_joined | A person added to the call answers and joins. | call_id, participant_id, role, agent_id, to, mode (talk, listen) |
call.participant_left | A person leaves the call or is removed. | call_id, participant_id, role, agent_id, to, reason |
callback.scheduled | A callback is scheduled for a lead, from a disposition or a voicemail. | callback_id, lead_id, agent_id, callback_at, source (disposition, voicemail), call_id |
Phone tree navigation
| Event | Sent when | Key fields |
|---|---|---|
ivr.detected | An answered line is a phone tree and navigation starts. | call_id, lead_id, target |
ivr.step | The navigator sends a key or a spoken option. | call_id, heard, sent, step |
ivr.screener_detected | A call-screening assistant or auto-attendant asks who is calling. | call_id, heard |
ivr.screener_answered | The agent's recorded screener line is played. | call_id, text, voice |
ivr.hold_started | The call is placed on hold. | call_id, at_ms |
ivr.hold_ended | Hold ends. | call_id, hold_seconds |
ivr.human_detected | A live person picks up inside the phone tree. | call_id, speech_ended_to_bridge_ms |
ivr.failed | Navigation stops without reaching a person. | call_id, reason (no_match, timeout, voicemail, screened_to_voicemail, max_steps), ivr_path |
Recording and analysis
| Event | Sent when | Key fields |
|---|---|---|
recording.started | The lead is connected and recording starts. | recording_id, call_id, lead_state, rule (one_party, all_party), sides (agent, both) |
recording.consent_given | Consent to record is marked on an all-party call. | recording_id, call_id, consented_by, method (announcement, agent_ask), at_ms, consent_audio_url |
recording.sides_changed | The recorded sides change: consent given, paused or resumed. | recording_id, call_id, sides (none, agent, both), reason |
recording.ready | A recording file is ready after hang-up. | recording_id, call_id, duration_seconds, sides_timeline, consent, url |
recording.deleted | A recording is deleted by a retention rule or your request. | recording_id, reason |
transcript.ready | The transcript is ready. | call_id, turns[speaker, start_ms, end_ms, text] |
tonality.ready | Tonality analysis is ready (add-on). | call_id, speakers[tone, energy, sentiment, talk_ratio] |
summary.ready | Summary and outcome fields are ready. | call_id, summary, outcome, callback_at, opt_out_requested |
Numbers and compliance
| Event | Sent when | Key fields |
|---|---|---|
number.health_changed | A number gains or loses a spam label, or its answer rate drops. | number_id, previous, current, labels, action_taken |
number.state_changed | A number is rested, retired, quarantined after carrier rejects, or reactivated. | number_id, state, reason, until |
number.cleaning_updated | Spam cleaning for a number changes status: filed, in_progress, cleared, or replaced after 14 days. | number_id, status, engines, replacement_number_id |
lookup.batch_completed | A batch of number lookups finished. | batch_id (lkb_), status, count, disconnected |
number.port_updated | A port request changes status. | port_id, status, numbers, foc_date |
dnc.added | A number is added to your do-not-call list. | phone, source (api, call_phrase, keypress, text_reply) |
compliance.cap_reached | A campaign's 30-day abandoned-call rate reaches 3%. The campaign dials one line per agent until the rate is back under the cap, and we email you. | campaign_id, rate_30d, cap |
Messaging
| Event | Sent when | Key fields |
|---|---|---|
message.sent | A message is accepted for delivery. | message_id, channel (imessage, sms, mms, whatsapp), from, to |
message.delivered | Delivery is confirmed. | message_id, channel, delivered_at |
message.failed | A message could not be delivered. | message_id, error |
message.queued | A message is held for the recipient's quiet hours. | message_id, channel, to, send_at, reason |
message.received | A reply or new message arrives on a messaging line. | message_id, channel, from, to, body, media_urls, lead_id, last_call_id |
message.opted_out | A recipient sends an opt-out word. Messages to the number stop and it is added to your do-not-call list. | phone, word, channel, message_id |
message.opted_in | A recipient sends START or UNSTOP and can receive messages again. | phone, word, channel, message_id |
Usage and invoices
| Event | Sent when | Key fields |
|---|---|---|
usage.recorded | A billable item is recorded. | item, quantity, unit_price, amount, call_id, account_id |
invoice.created | The monthly invoice is issued. | invoice_id, account_id, period_start, period_end, total, due_at |
invoice.paid | An invoice is paid. | invoice_id, account_id, total, paid_at |
invoice.payment_failed | A payment for an invoice fails. | invoice_id, account_id, total, error, next_attempt_at |
Accounts
| Event | Sent when | Key fields |
|---|---|---|
account.requirements_updated | An item is added to or cleared from a subaccount's requirements. | account_id, currently_due, pending_verification |
account.capability_updated | A feature of a subaccount turns on or off. | account_id, capability, status |
account.document_verified | An uploaded document is verified. | account_id, document_id, type |
account.document_rejected | An uploaded document is rejected. | account_id, document_id, type, reason |
account.reverification_due | An item needs to be sent again, 60 and 30 days before it expires. | account_id, items, due_at |
account.notice_sent | We send a compliance notice to the account by email. | account_id, notice_id, subject, reply_by |