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_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"] }
    }
  }
]
ПолеТипОпис
rolestringuser, model, tool або system
content_typestringtext/plain для мовлення; application/json для викликів інструментів, результатів інструментів і системних подій
contentstring | objectТекст мовлення або структурований об’єкт, показаний вище. Виклики інструментів: {"tool_call": name, "arguments": {…}}. Результати інструментів: {"tool_name": name, "response": {…}}
start_ms, end_msintegerЗміщення від початку виклику, мс. Наявні, коли відомий час аудіо
ttfa_msintegerЧас до першого аудіо для ходу model, якщо виміряно
audio_url, audio_urlsstring / 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}, щоб повідомити, що ціль переадресації ще не готова. Будь-яка інша відповідь (або відсутність застарілого вебхука) дозволяє продовжити переадресацію. Доставки ендпойнта для цього ніколи не використовуються.

Приклад обробника

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 або моніторингу частоти переадресацій.

Перевірка якості

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

Сповіщення

Сповіщайте співробітника команди про переадресацію / збій.


Пов’язані матеріали