ThunderPhone 2.0 уже доступен.Самостоятельное подключение — от 2 центов/мин.Читать анонс

Webhooks

telephony.complete / web.complete

Неблокирующий вебхук, отправляемый при завершении звонка, с расшифровкой, URL записи и метриками.

Событие завершения отправляется после окончания каждого звонка — входящей телефонии, исходящей телефонии, веб-звонка или тестового звонка (сеанса с микрофоном в конструкторе). Оно не блокирует выполнение: ответьте любым кодом 2xx.

Событие доставляется по обоим путям:

Полезная нагрузка запроса (доставки на конечные точки)

{
  "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_idintegerНеизменно для каждого события этого звонка
directionstringinbound, outbound, web, test. Исторические полезные нагрузки могут содержать устаревшие значения mic или widget
from_number, to_numberstringE.164. Для веб-звонков и тестовых звонков from_number имеет буквальное значение "web"
origin_domainstringТолько для веб-звонков/тестов — источник страницы, на которой размещён виджет (пустое значение для сеансов с микрофоном)
start_time, end_timetimestampISO 8601 UTC
duration_secondsinteger | nullВычисляется на основе времени начала и окончания
statusstringcompleted или failed
end_reasonstringСм. таблицу ниже
product, voicestringКонфигурация агента, действовавшая во время звонка
transfer_numberstring | nullУстанавливается при переводе звонка
recording_urlstring | nullПодписанный URL с ограниченным сроком действия; скачайте без промедления. null, если запись недоступна
billable_minutesnumberТарифицируемые минуты, округлённые до ближайшей четверти минуты (шаг 15 секунд, минимум 0.25). Звонки, сразу переведённые на голосовую почту, по-прежнему указывают здесь фактические тарифицируемые минуты, но плата ограничена одной минутой по тарифу плана.
billing_total_centsintegerЦенты USD
transcriptsarrayЗаписи расшифровки для каждого хода; могут быть пустыми, если расшифровка недоступна

Причины завершения

ЗначениеЗначение
user_hangupУдалённая сторона первой завершила звонок
ai_hangupAI намеренно завершил звонок
ai_transferAI перевёл звонок; transfer_number установлен
ai_warm_transferAI завершил тёплый перевод с участием оператора
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) позволяет продолжить перевод. Доставки конечной точки для этого никогда не используются.

Пример обработчика

Python (FastAPI)
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}
Node.js (Express)
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 });
  },
);

Распространённые сценарии использования

Интеграция с CRM

Сохраняйте расшифровку каждого звонка и URL записи вместе с данными клиентов.

Аналитика

Передавайте расшифровки в конвейер для тематического моделирования, извлечения сигналов CSAT или мониторинга доли переводов.

Проверка качества

Открывайте звонки в инструменте QA для проверки человеком или обрабатывайте их собственной моделью оценки.

Уведомления

Уведомляйте сотрудника при переводе или сбое.


Связанные материалы