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

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"
    ]
  }'
Response
{
  "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.

Event
{
  "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-id and skip ids you have already handled.
  • Events can arrive out of order. Sort by created_at, or read the state in data, 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.

Headers
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);
});

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 POST/v1/webhook_endpoints/whe_7Kc02/rotate_secret
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);
  }
}
  • For state such as a call's status, use the state in data and created_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:

  1. 5 seconds
  2. 5 minutes
  3. 30 minutes
  4. 2 hours
  5. 5 hours
  6. 10 hours
  7. 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
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

EventSent whenKey fields
dial_session.startedA session starts for an agent.dial_session_id, agent_id, lead_list_id, lines
dial_session.round_startedA new set of legs is placed for the agent.dial_session_id, lines, lead_ids
dial_session.pausedThe session is paused by your app, or because the agent disconnected.dial_session_id, reason (api, agent_disconnected)
dial_session.resumedA paused session starts dialing again.dial_session_id
dial_session.lines_changedWith 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.stoppedThe session ends: stopped, idle for 15 minutes, list empty or calling hours closed.dial_session_id, reason, totals

Lead lists and campaigns

EventSent whenKey fields
lead.status_changedA 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.emptiedA lead list has no lead left that can be dialed now.lead_list_id, campaign_id, counts
compliance.cap_clearedA campaign's 30-day abandoned-call rate is back under the cap and normal line counts return.campaign_id, rate_30d, cap

Agents

EventSent whenKey fields
agent.connectedAn agent connects from the browser, a mobile app or a SIP phone.agent_id, via (browser, ios, android, sip), user_agent
agent.disconnectedAn agent's connection closes. Their dial session pauses.agent_id, via, reason (disconnect, network, token_expired, replaced)

Legs and calls

EventSent whenKey fields
leg.ringingA leg starts ringing.leg_id, call_id, to, from
leg.answeredA leg is answered, before detection decides.leg_id, call_id, answered_at
leg.canceledA leg is canceled because another leg was bridged.leg_id, rang_ms
leg.no_answerA leg rings for the full ring time and nobody answers. The leg result is no_answer.leg_id, call_id, rang_ms
leg.failedThe carrier rejects the leg: busy, invalid number, network error.leg_id, cause, sip_code
amd.completedDetection decides on an answered leg.call_id, result, speech_ended_to_bridge_ms, action
call.bridgedA live person is connected to an agent.call_id, agent_id, speech_ended_to_bridge_ms, legs_canceled, ivr_path, hold_seconds
call.abandonedA live person answered and no agent was free.call_id, message_played, campaign_rate_30d
call.completedA bridged call ends.call_id, billed_seconds, hangup_by, disposition
call.blockedA 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.routedA 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.transferredA 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.updatedA live call is put on hold, taken off hold, muted or unmuted.call_id, action, on_hold, muted, by (api, agent)
call.participant_joinedA person added to the call answers and joins.call_id, participant_id, role, agent_id, to, mode (talk, listen)
call.participant_leftA person leaves the call or is removed.call_id, participant_id, role, agent_id, to, reason
callback.scheduledA 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

EventSent whenKey fields
ivr.detectedAn answered line is a phone tree and navigation starts.call_id, lead_id, target
ivr.stepThe navigator sends a key or a spoken option.call_id, heard, sent, step
ivr.screener_detectedA call-screening assistant or auto-attendant asks who is calling.call_id, heard
ivr.screener_answeredThe agent's recorded screener line is played.call_id, text, voice
ivr.hold_startedThe call is placed on hold.call_id, at_ms
ivr.hold_endedHold ends.call_id, hold_seconds
ivr.human_detectedA live person picks up inside the phone tree.call_id, speech_ended_to_bridge_ms
ivr.failedNavigation stops without reaching a person.call_id, reason (no_match, timeout, voicemail, screened_to_voicemail, max_steps), ivr_path

Recording and analysis

EventSent whenKey fields
recording.startedThe lead is connected and recording starts.recording_id, call_id, lead_state, rule (one_party, all_party), sides (agent, both)
recording.sides_changedThe recorded sides change: consent given, paused or resumed.recording_id, call_id, sides (none, agent, both), reason
recording.readyA recording file is ready after hang-up.recording_id, call_id, duration_seconds, sides_timeline, consent, url
recording.deletedA recording is deleted by a retention rule or your request.recording_id, reason
transcript.readyThe transcript is ready.call_id, turns[speaker, start_ms, end_ms, text]
tonality.readyTonality analysis is ready (add-on).call_id, speakers[tone, energy, sentiment, talk_ratio]
summary.readySummary and outcome fields are ready.call_id, summary, outcome, callback_at, opt_out_requested

Numbers and compliance

EventSent whenKey fields
number.health_changedA number gains or loses a spam label, or its answer rate drops.number_id, previous, current, labels, action_taken
number.state_changedA number is rested, retired, quarantined after carrier rejects, or reactivated.number_id, state, reason, until
number.cleaning_updatedSpam cleaning for a number changes status: filed, in_progress, cleared, or replaced after 14 days.number_id, status, engines, replacement_number_id
lookup.batch_completedA batch of number lookups finished.batch_id (lkb_), status, count, disconnected
number.port_updatedA port request changes status.port_id, status, numbers, foc_date
dnc.addedA number is added to your do-not-call list.phone, source (api, call_phrase, keypress, text_reply)
compliance.cap_reachedA 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

EventSent whenKey fields
message.sentA message is accepted for delivery.message_id, channel (imessage, sms, mms, whatsapp), from, to
message.deliveredDelivery is confirmed.message_id, channel, delivered_at
message.failedA message could not be delivered.message_id, error
message.queuedA message is held for the recipient's quiet hours.message_id, channel, to, send_at, reason
message.receivedA reply or new message arrives on a messaging line.message_id, channel, from, to, body, media_urls, lead_id, last_call_id
message.opted_outA 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_inA recipient sends START or UNSTOP and can receive messages again.phone, word, channel, message_id

Usage and invoices

EventSent whenKey fields
usage.recordedA billable item is recorded.item, quantity, unit_price, amount, call_id, account_id
invoice.createdThe monthly invoice is issued.invoice_id, account_id, period_start, period_end, total, due_at
invoice.paidAn invoice is paid.invoice_id, account_id, total, paid_at
invoice.payment_failedA payment for an invoice fails.invoice_id, account_id, total, error, next_attempt_at

Accounts

EventSent whenKey fields
account.requirements_updatedAn item is added to or cleared from a subaccount's requirements.account_id, currently_due, pending_verification
account.capability_updatedA feature of a subaccount turns on or off.account_id, capability, status
account.document_verifiedAn uploaded document is verified.account_id, document_id, type
account.document_rejectedAn uploaded document is rejected.account_id, document_id, type, reason
account.reverification_dueAn item needs to be sent again, 60 and 30 days before it expires.account_id, items, due_at
account.notice_sentWe send a compliance notice to the account by email.account_id, notice_id, subject, reply_by