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_transfer | ШІ перевів виклик; задано transfer_number |
ai_warm_transfer | ШІ виконав тепле переведення виклику (з супроводом) |
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 | 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 відрізняється від навантаження ендпойнта:
- Масив ходів міститься в
history, а не вtranscripts(та сама схема ходів, що й вище). - Набір полів є необробленим звітом наприкінці виклику та може містити додаткові внутрішні поля поза наведеною вище таблицею — вважайте невідомі поля інформаційними.
- Вебвиклики (
direction: "web") не містятьfrom_number/to_numberі додаютьorigin_domain. - Тестові виклики мікрофона в Builder надсилаються як
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 або моніторингу частоти переадресацій.
Відкривайте дзвінки в інструменті контролю якості для ручної перевірки або пропускайте їх через власну модель оцінювання.
Сповіщайте співробітника команди про переадресацію / збій.