telephony.complete / web.complete
Nieblokujący webhook dostarczany po zakończeniu połączenia, zawierający transkrypcję, adres URL nagrania i metryki.
Zdarzenie zakończenia jest wywoływane po zakończeniu każdego połączenia — telefonii przychodzącej, telefonii wychodzącej, połączenia internetowego lub połączenia testowego (sesja mikrofonu konstruktora). Jest nieblokujące: odpowiedz dowolnym kodem 2xx.
Zdarzenie jest dostarczane obiema ścieżkami:
- Endpointy webhooków otrzymują
telephony.complete(połączenia telefoniczne) lubweb.complete(połączenia internetowe oraz testowe połączenia mikrofonowe konstruktora) ze stabilnym ładunkiem opisanym poniżej, unikalnym dla każdego dostarczeniaevent_id, limitem czasu 30 s oraz ponownymi próbami przez maksymalnie 24 h. - Starszy webhook z jednym adresem URL otrzymuje jedną synchroniczną próbę (limit czasu 10 s, bez ponownych prób) z nieco innym ładunkiem — zobacz Różnice w starszym ładunku.
Ładunek żądania (dostarczenia do endpointów)
{
"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"
}| Pole | Typ | Opis |
|---|---|---|
call_id | integer | Stałe dla każdego zdarzenia tego połączenia |
direction | string | inbound, outbound, web, test. Historyczne ładunki mogą zawierać starsze wartości mic lub widget |
from_number, to_number | string | E.164. W przypadku połączeń internetowych i testowych from_number ma dosłowną wartość "web" |
origin_domain | string | Tylko internet/test — źródło strony, na której hostowano widżet (puste dla sesji mikrofonowych) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | integer | null | Wyprowadzane na podstawie czasu rozpoczęcia i zakończenia |
status | string | completed lub failed |
end_reason | string | Zobacz tabelę poniżej |
product, voice | string | Konfiguracja agenta obowiązująca w chwili połączenia |
transfer_number | string | null | Ustawiane po przekazaniu połączenia |
recording_url | string | null | Wygasający podpisany URL; pobierz niezwłocznie. null, gdy artefakt nagrania nie jest dostępny |
billable_minutes | number | Minuty rozliczane, zaokrąglone do najbliższej ćwierćminuty (przyrosty co 15 sekund, minimum 0,25). Połączenia trafiające bezpośrednio na pocztę głosową nadal zgłaszają tutaj faktycznie naliczone minuty, ale opłata jest ograniczona do jednej minuty według stawki planu. |
billing_total_cents | integer | Centy USD |
transcripts | array | Wpisy transkrypcji dla każdej tury; mogą być puste, gdy transkrypcja jest niedostępna |
Powody zakończenia
| Wartość | Znaczenie |
|---|---|
user_hangup | Rozmówca rozłączył się jako pierwszy |
ai_hangup | AI celowo zakończyła połączenie |
ai_transfer | AI przekazała połączenie; ustawiono transfer_number |
ai_warm_transfer | AI ukończyła transfer konsultowany |
voicemail_hangup | Wykryto pocztę głosową, a połączenie zakończono zgodnie z voicemail_action |
max_duration | Połączenie osiągnęło maksymalny limit czasu trwania |
superseded | Sesja została zastąpiona nowszą |
unknown | Nie można było ustalić powodu zakończenia |
Format transkrypcji
Każdy wpis w transcripts to jedna tura rozmowy. Role to
user (wypowiedź rozmówcy), model (wypowiedź agenta oraz wywołania narzędzi),
tool (wyniki narzędzi) i system (zdarzenia połączenia, takie jak zmiany
języka).
[
{
"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"] }
}
}
]| Pole | Typ | Opis |
|---|---|---|
role | ciąg znaków | user, model, tool lub system |
content_type | ciąg znaków | text/plain dla wypowiedzi; application/json dla wywołań narzędzi, wyników narzędzi i zdarzeń systemowych |
content | ciąg znaków | obiekt | Tekst wypowiedzi lub obiekt strukturalny przedstawiony powyżej. Wywołania narzędzi: {"tool_call": name, "arguments": {…}}. Wyniki narzędzi: {"tool_name": name, "response": {…}} |
start_ms, end_ms | liczba całkowita | Przesunięcia względem początku połączenia w ms. Obecne, gdy znane jest czasowanie audio |
ttfa_ms | liczba całkowita | Czas do pierwszego dźwięku dla tury model, gdy jest mierzony |
audio_url, audio_urls | ciąg znaków / tablica | Wygasające podpisane adresy URL do audio danej tury, gdy nagranie jest dostępne dla poszczególnych tur |
Aby uzyskać w pełni ustrukturyzowaną historię tur (ze znacznikami
przerwań, komunikatami potwierdzającymi i surowymi pozycjami), użyj
GET /v1/calls/{call_id}/history.
Różnice w starszym ładunku
Starsza koperta webhooka z pojedynczym adresem URL ma postać
{"type": "telephony.complete" | "web.complete", "data": {…}} i
nie zawiera event_id, a jej data różni się od ładunku punktu końcowego:
- Tablica tur znajduje się w
history, a nie wtranscripts(ten sam schemat tury co powyżej). - Zestaw pól to surowy raport końca połączenia i może zawierać dodatkowe pola wewnętrzne poza powyższą tabelą — traktuj nieznane pola jako informacyjne.
- Połączenia internetowe (
direction: "web") pomijająfrom_number/to_numberi dodająorigin_domain. - Połączenia testowe mikrofonu w kreatorze są raportowane jako
telephony.completena starszej ścieżce (system punktu końcowego mapuje je naweb.complete). - Koordynacja przekazania: gdy połączenie kończy się przekazaniem,
starszy webhook jest wywoływany synchronicznie i może zwrócić
{"transfer_ready": false}, aby zasygnalizować, że cel przekazania nie jest gotowy. Każda inna odpowiedź (lub brak starszego webhooka) pozwala na kontynuowanie przekazania. Dostawy do punktu końcowego nigdy nie są w tym celu sprawdzane.
Przykładowa obsługa
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 });
},
);Typowe przypadki użycia
Zapisuj transkrypcję i adres URL nagrania każdego połączenia wraz z danymi klientów.
Przesyłaj transkrypcje strumieniowo do potoku na potrzeby modelowania tematów, wyodrębniania sygnałów CSAT lub monitorowania współczynnika transferów.
Otwieraj połączenia w narzędziu QA do ręcznej weryfikacji lub przetwarzaj je za pomocą własnego modelu oceny.
Powiadamiaj członka zespołu o transferze / błędzie.