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",
"from_number": "+14155550199",
"product": "spark",
"recording_url": "https://storage.example.com/…",
"start_time": "2026-04-20T18:24:10.113Z",
"status": "completed",
"to_number": "+15551234567",
"transcripts": [ /* see Transcript format */ ],
"transfer_number": null,
"voice": "john"
},
"event_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
"type": "telephony.complete"
}| Поле | Тип | Описание |
|---|---|---|
call_id | integer | Неизменно для каждого события этого звонка |
direction | string | inbound, outbound, web, test. Исторические полезные нагрузки могут содержать устаревшие значения mic или widget |
from_number, to_number | string | E.164. Для веб-звонков и тестовых звонков from_number имеет буквальное значение "web" |
origin_domain | string | Только для веб-звонков/тестов — источник страницы, на которой размещён виджет (пустое значение для сеансов с микрофоном) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | integer | null | Вычисляется на основе времени начала и окончания |
status | string | completed или failed |
end_reason | string | См. таблицу ниже |
product, voice | string | Конфигурация агента, действовавшая во время звонка |
transfer_number | string | null | Устанавливается при переводе звонка |
recording_url | string | null | Подписанный URL с ограниченным сроком действия; скачайте без промедления. null, если запись недоступна |
billable_minutes | number | Тарифицируемые минуты, округлённые до ближайшей четверти минуты (шаг 15 секунд, минимум 0.25). Звонки, сразу переведённые на голосовую почту, по-прежнему указывают здесь фактические тарифицируемые минуты, но плата ограничена одной минутой по тарифу плана. |
billing_total_cents | integer | Центы USD |
transcripts | array | Записи расшифровки для каждого хода; могут быть пустыми, если расшифровка недоступна |
Причины завершения
| Значение | Значение |
|---|---|
user_hangup | Удалённая сторона первой завершила звонок |
ai_hangup | AI намеренно завершил звонок |
ai_transfer | AI перевёл звонок; transfer_number установлен |
ai_warm_transfer | AI завершил тёплый перевод с участием оператора |
voicemail_hangup | Обнаружена голосовая почта, и звонок завершён согласно вашему voicemail_action |
max_duration | Звонок достиг максимальной длительности |
superseded | Сеанс был заменён более новым |
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 | строка | user, model, tool или system |
content_type | строка | text/plain для речи; application/json для вызовов инструментов, результатов инструментов и системных событий |
content | строка | объект | Текст речи или структурированный объект, показанный выше. Вызовы инструментов: {"tool_call": name, "arguments": {…}}. Результаты инструментов: {"tool_name": name, "response": {…}} |
start_ms, end_ms | целое число | Смещения от начала звонка в мс. Присутствуют, когда известно время аудио |
ttfa_ms | целое число | Время до первого аудио для реплики model, если оно измерено |
audio_url, audio_urls | строка / массив | Временные подписанные URL-адреса для аудио реплики, если аудио записывается отдельно для каждой реплики |
Для полной структурированной истории реплик (с маркерами прерываний,
запросами подтверждения и исходными позициями) используйте
GET /v1/calls/{call_id}/history.
Отличия устаревшего payload
Устаревший webhook-конверт с одним URL имеет вид
{"type": "telephony.complete" | "web.complete", "data": {…}} и
не содержит event_id; его data отличается от payload конечной точки:
- Массив реплик находится в
history, а не вtranscripts(та же схема реплик, что выше). - Набор полей представляет собой необработанный отчёт по завершении звонка и может включать дополнительные внутренние поля помимо приведённых в таблице — считайте неизвестные поля информационными.
- Веб-звонки (
direction: "web") не содержатfrom_number/to_numberи добавляютorigin_domain. - Тестовые звонки с микрофона в конструкторе передаются как
telephony.completeпо устаревшему пути (система конечной точки сопоставляет их сweb.complete). - Координация перевода: если звонок завершается переводом, устаревший webhook
вызывается синхронно и может вернуть
{"transfer_ready": false}, чтобы указать, что целевой участник перевода ещё не готов. Любой другой ответ (или отсутствие устаревшего webhook) позволяет продолжить перевод. Доставки конечной точки для этого никогда не используются.
Пример обработчика
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 для проверки человеком или обрабатывайте их собственной моделью оценки.
Уведомляйте сотрудника при переводе или сбое.