Open in
telephony.complete / web.complete
Неблокирующий вебхук, отправляемый при завершении звонка, с расшифровкой, URL записи и метриками.
Событие завершения срабатывает после окончания каждого звонка — входящего телефонного, исходящего телефонного, веб-звонка или тестового звонка (сеанс с микрофоном в конструкторе). Оно не блокирует: ответьте любым кодом 2xx.
Событие доставляется по обоим путям:
- Конечные точки вебхуков получают
telephony.complete(телефонные звонки) илиweb.complete(веб-звонки и тестовые звонки с микрофоном в конструкторе) со стабильной полезной нагрузкой, описанной ниже, отдельнымevent_idдля каждой доставки, тайм-аутом 30 с и повторными попытками в течение до 24 ч. - Устаревший вебхук с одним URL получает одну синхронную попытку (тайм-аут 10 с, без повторных попыток) с немного другой полезной нагрузкой — см. Различия в устаревшей полезной нагрузке.
Тело запроса (доставки на конечную точку)
{
"data": {
"billable_minutes": 1.25,
"billing_total_cents": 8,
"call_id": 987654321,
"direction": "inbound",
"duration_seconds": 54,
"end_reason": "user_hangup",
"end_time": "2026-04-20T18:25:04.822Z",
"extracted_data": {
"status": "completed",
"fields": {
"customer_name": "Alex Morgan",
"appointment_date": "2026-04-23"
},
"evidence": {
"customer_name": {
"quote": "My name is Alex Morgan",
"speaker_role": "caller",
"turn_index": 4
},
"appointment_date": {
"quote": "April 23 works for me",
"speaker_role": "caller",
"turn_index": 7
}
},
"verification": "verified",
"field_reasons": {},
"schema_version": "92850758e231a3c95a..."
},
"from_number": "+14155550199",
"product": "spark",
"recording_stopped_at": null,
"recording_stopped_reason": null,
"recording_url": "https://storage.example.com/…",
"start_time": "2026-04-20T18:24:10.113Z",
"status": "completed",
"summary": "Alex Morgan called to book a follow-up visit and confirmed Thursday, April 23. The agent read the appointment back and Alex asked for a reminder text the day before.",
"to_number": "+15551234567",
"transcripts": [ /* see Transcript format */ ],
"transfer_number": null,
"unresolved_variables": ["campaign_owner"],
"variables": {"campaign_name": "Spring renewals"},
"voice": "john"
},
"event_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
"type": "telephony.complete"
}| Поле | Тип | Описание |
|---|---|---|
call_id | integer | Неизменно для каждого события этого вызова |
agent_id | integer | null | Агент, обработавший вызов, если он был назначен |
agent_name | string | null | Агент, обработавший вызов, если он был назначен |
direction | string | inbound, outbound, web, test. Исторические полезные нагрузки могут содержать устаревшие значения mic или widget |
from_number, to_number | string | E.164. Для веб- и тестовых вызовов from_number имеет буквальное значение "web" |
origin_domain | string | Только для веб- и тестовых вызовов — источник страницы, на которой размещён виджет (пусто для сеансов mic) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | integer | null | Вычисляется по времени начала и окончания |
status | string | completed или failed |
end_reason | string | См. таблицу ниже |
product, voice | string | Конфигурация агента, действовавшая во время вызова |
variables | object | Входные переменные, зафиксированные при начале вызова |
unresolved_variables | array | Имена переменных, на которые ссылается конфигурация вызова, но которые не были переданы при начале вызова |
transfer_number | string | null | Устанавливается при переводе вызова |
recording_url | string | null | Подписанный URL с ограниченным сроком действия; скачайте его без промедления. null, если запись недоступна |
recording_stopped_at | timestamp | null | Момент остановки записи до завершения вызова; после этого момента запись ничего не содержит. null, если запись велась до конца вызова или вызов не записывался |
recording_stopped_reason | string | null | Причина досрочной остановки записи. warm_transfer: запись останавливается непосредственно перед тем, как тёплый перевод вызывает получателя, и остаётся выключенной, даже если перевод не удался и звонящий вернулся к агенту. Считайте другие значения информационными; могут быть добавлены новые. null, если recording_stopped_at имеет значение null |
billable_minutes | number | Тарифицируемые минуты, округлённые до ближайшей четверти минуты (интервалы по 15 секунд, минимум 0.25). Вызовы, сразу перенаправленные на голосовую почту, по-прежнему указывают здесь фактическое количество тарифицируемых минут, но плата ограничена одной минутой по тарифу плана. |
billing_total_cents | integer | Центы USD |
transcripts | array | Записи расшифровки для каждого хода; могут быть пустыми, если расшифровка недоступна |
summary | string | null | Итог вызова на естественном языке (от двух до четырёх предложений прозы, не более 2 000 символов, на языке вызова: основном языке агента, если вызов не переопределил его), когда у агента включён параметр Создавать итог вызова в разделе Данные для сбора конструктора агента (call_summary_enabled у Агента) и создание выполнено успешно; в остальных случаях null, в том числе для вызовов, слишком коротких для создания итога, вызовов без расшифровки и агентов с отключённой записью. Итог создаётся на том же этапе после вызова, что и извлечение данных, поэтому параметр Ожидать извлечённые данные перед отправкой вебхука о завершении вызова (extraction_blocks_completion) определяет, ожидает ли это событие его создания: в режиме ожидания итог готов до отправки этого события (самому итогу отводится не более 30 секунд из 75-секундного бюджета этапа); если событие не ожидает (never или перевод при except_transfers), это поле имеет значение null, а итог не отправляется повторно в call.data_extracted, поэтому прочитайте его позже из summary в объекте вызова. Также доступно на странице вызова в панели управления |
extracted_data | object | null | Структурированный результат извлечения с полями status, fields, evidence, verification, field_reasons и schema_version. Каждое поле, отличное от null, содержит точную структурно проверенную цитату (не более 1 000 символов), а также роль говорящего и индекс хода; более длинные цитаты, возвращённые моделью, отклоняются, а не обрезаются. Доказательство имеет значение null, если поле имеет значение null. verified означает, что каждый кандидат получил ровно один допустимый независимый вердикт. unavailable также охватывает некорректный или частичный вывод верификатора; допустимые частичные вердикты по-прежнему применяются, а кандидаты без одного допустимого вердикта обнуляются. status имеет значение completed, failed, exhausted, skipped или skipped_recording_disabled; null, если у агента не было полей извлечения |
steps_trace | array | Только для вызовов, агент которых имеет этапы или передачи. Порядок, в котором вызов проходил через них: каждая запись содержит t_ms (миллисекунды с начала вызова), agent (ключ агента, a плюс его ID), step (ID этапа или null при передаче агенту без этапов) и via (start, model, condition, on_enter или handoff). Содержит только ID и время, поэтому сохраняется даже при отключённой записи |
collected_values | object | Только для вызовов, агент которых имеет этапы или передачи. Значения, собранные агентом на его этапах, с ключами по имени поля сбора, а также <tool>_result для результата on_enter_tool каждого этапа. Не включается, если содержимое вызова не сохраняется (запись отключена) |
Причины завершения
| Значение | Описание |
|---|---|
user_hangup | Удалённая сторона завершила вызов первой |
ai_hangup | ИИ намеренно завершил вызов |
ai_transfer | ИИ перевёл вызов; задан transfer_number |
ai_warm_transfer | ИИ выполнил тёплый перевод вызова с участием оператора |
voicemail_hangup | Обнаружена голосовая почта, и вызов завершён в соответствии с вашим voicemail_action |
max_duration | Вызов достиг максимальной длительности: лимита агента, собственного лимита вызова или 6-часового лимита платформы. Вызов, который ИИ или звонящий завершает после предупреждения об ограничении времени, сохраняет ai_hangup или user_hangup |
superseded | Сессия была заменена более новой |
inbound_config_webhook_failed | Входящий вызов на номер в режиме вебхука: вебхук конфигурации не вернул пригодную конфигурацию, поэтому вызов был завершён до ответа агента. status имеет значение failed, и вызов не тарифицируется |
unknown | Не удалось определить причину завершения |
Формат расшифровки
Каждая запись в transcripts — один ход диалога. Роли:
user (речь звонящего), model (речь агента и вызовы инструментов),
tool (результаты инструментов) и system (события звонка, например смена
языка).
[
{
"role": "user",
"content_type": "text/plain",
"content": "Hi, I'm calling about my appointment.",
"start_ms": 1200,
"end_ms": 4100,
"audio_url": "https://storage.example.com/…"
},
{
"role": "model",
"content_type": "text/plain",
"content": "Sure, what date works best?",
"start_ms": 4200,
"end_ms": 6100
},
{
"role": "model",
"content_type": "application/json",
"content": {
"tool_call": "search_appointments",
"arguments": { "date": "2026-04-21" }
}
},
{
"role": "tool",
"content_type": "application/json",
"content": {
"tool_name": "search_appointments",
"response": { "available_slots": ["9:00 AM", "2:00 PM"] }
}
}
]| Поле | Тип | Описание |
|---|---|---|
role | string | user, model, tool или system |
content_type | string | text/plain для речи; application/json для вызовов инструментов, результатов инструментов и системных событий |
content | string | object | Текст речи или структурированный объект, показанный выше. Вызовы инструментов: {"tool_call": name, "arguments": {…}}. Результаты инструментов: {"tool_name": name, "response": {…}} |
start_ms, end_ms | integer | Смещения от начала звонка в мс. Присутствуют, когда известно время аудио |
ttfa_ms | integer | Время до первого аудио для хода model, если оно измерено |
audio_url, audio_urls | string / array | Временные подписанные URL-адреса аудио для хода, если запись ведётся для каждого хода |
Для получения полностью структурированной истории ходов (с маркерами
прерываний, запросами подтверждения и исходными позициями) используйте
GET /v1/calls/{call_id}/history.
Отличия устаревшей полезной нагрузки
Устаревшая обёртка вебхука с одним URL имеет вид
{"type": "telephony.complete" | "web.complete", "data": {…}}, содержит
без event_id, а её data отличается от полезной нагрузки эндпоинта:
Устаревшая полезная нагрузка завершения также содержит agent_id и agent_name.
- Массив ходов находится в
history, а не вtranscripts(та же схема ходов, что и выше). - Набор полей представляет собой исходный отчёт об окончании звонка и может включать дополнительные внутренние поля помимо приведённых в таблице — считайте неизвестные поля информационными.
- Веб-звонки (
direction: "web") не содержатfrom_number/to_numberи добавляютorigin_domain. - Тестовые звонки с микрофона в конструкторе передаются как
telephony.completeпо устаревшему пути (система эндпоинтов сопоставляет их сweb.complete). - Координация перевода звонка: когда звонок заканчивается переводом, устаревший
вебхук вызывается синхронно и может вернуть
{"transfer_ready": false}, чтобы указать, что целевой получатель перевода не готов. Любой другой ответ (или отсутствие устаревшего вебхука) позволяет продолжить перевод. Доставки через эндпоинт для этого никогда не используются.
Пример обработчика
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["THUNDERPHONE_WEBHOOK_SECRET"]
def verify(body: bytes, signature: str) -> bool:
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
@app.post("/thunderphone-webhook")
async def webhook(request: Request):
body = await request.body()
if not verify(body, request.headers.get("X-ThunderPhone-Signature", "")):
raise HTTPException(status_code=401)
event = json.loads(body)
if event["type"] in ("telephony.complete", "web.complete"):
data = event["data"]
# Endpoint deliveries use "transcripts"; the legacy webhook uses "history".
turns = data.get("transcripts") or data.get("history") or []
await persist_call_record(
call_id=data["call_id"],
turns=turns,
recording_url=data.get("recording_url"),
)
if data["end_reason"] in ("ai_transfer", "ai_warm_transfer"):
await notify_team(data.get("transfer_number"), data["call_id"])
return {"ok": True}import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.THUNDERPHONE_WEBHOOK_SECRET;
function verify(body, signature) {
const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
return signature &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
app.post(
"/thunderphone-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
if (!verify(req.body, req.header("X-ThunderPhone-Signature"))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (["telephony.complete", "web.complete"].includes(event.type)) {
const data = event.data;
// Endpoint deliveries use "transcripts"; the legacy webhook uses "history".
const turns = data.transcripts ?? data.history ?? [];
await persistCallRecord({ ...data, turns });
if (["ai_transfer", "ai_warm_transfer"].includes(data.end_reason)) {
await notifyTeam(data.transfer_number, data.call_id);
}
}
res.json({ ok: true });
},
);Распространённые сценарии использования
Сохраняйте расшифровку каждого звонка и URL записи вместе с данными клиента.
Передавайте расшифровки в конвейер для тематического моделирования, извлечения сигналов CSAT или мониторинга частоты переводов.
Открывайте звонки в инструменте QA для проверки человеком или запускайте их через собственную модель оценки.
Уведомляйте коллегу при переводе или сбое.