Open in
telephony.complete / web.complete
Nieblokujący webhook dostarczany po zakończeniu połączenia, zawierający transkrypcję, 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 (sesji mikrofonu w kreatorze). Jest nieblokujące: odpowiedz dowolnym kodem 2xx.
Zdarzenie jest dostarczane obiema ścieżkami:
- Punkty końcowe webhooków otrzymują
telephony.complete(połączenia telefoniczne) lubweb.complete(połączenia internetowe i testowe połączenia mikrofonowe w kreatorze) ze stabilnym ładunkiem opisanym poniżej, identyfikatoremevent_iddla każdego dostarczenia, limitem czasu 30 s oraz ponownymi próbami przez maksymalnie 24 h. - Starszy webhook z pojedynczym 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.
Treść żądania (dostarczenia do endpointu)
{
"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",
"extracted_data": {
"status": "completed",
"fields": {
"customer_name": "Alex Morgan",
"appointment_date": "2026-04-23"
},
"evidence": {
"customer_name": {
"quote": "My name is Alex Morgan",
"speaker_role": "caller",
"turn_index": 4
},
"appointment_date": {
"quote": "April 23 works for me",
"speaker_role": "caller",
"turn_index": 7
}
},
"verification": "verified",
"field_reasons": {},
"schema_version": "92850758e231a3c95a..."
},
"from_number": "+14155550199",
"product": "spark",
"recording_stopped_at": null,
"recording_stopped_reason": null,
"recording_url": "https://storage.example.com/…",
"start_time": "2026-04-20T18:24:10.113Z",
"status": "completed",
"summary": "Alex Morgan called to book a follow-up visit and confirmed Thursday, April 23. The agent read the appointment back and Alex asked for a reminder text the day before.",
"to_number": "+15551234567",
"transcripts": [ /* see Transcript format */ ],
"transfer_number": null,
"unresolved_variables": ["campaign_owner"],
"variables": {"campaign_name": "Spring renewals"},
"voice": "john"
},
"event_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
"type": "telephony.complete"
}| Pole | Typ | Opis |
|---|---|---|
call_id | integer | Stałe we wszystkich zdarzeniach dla tego połączenia |
agent_id | integer | null | Agent, który obsłużył połączenie, jeśli został przypisany |
agent_name | string | null | Agent, który obsłużył połączenie, jeśli został przypisany |
direction | string | inbound, outbound, web, test. Historyczne ładunki mogą zawierać starsze wartości mic lub widget |
from_number, to_number | string | E.164. Dla połączeń webowych i testowych from_number ma dosłowną wartość "web" |
origin_domain | string | Tylko web/test — źródło strony, na której hostowano widżet (puste dla sesji mic) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | integer | null | Wyliczane 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 momencie połączenia |
variables | object | Zmienne wejściowe zapisane w momencie rozpoczęcia połączenia |
unresolved_variables | array | Nazwy zmiennych, do których odwołuje się konfiguracja połączenia, ale które nie zostały podane przy rozpoczęciu połączenia |
transfer_number | string | null | Ustawiane, gdy połączenie zostało przekazane |
recording_url | string | null | Wygasający podpisany URL; pobierz go niezwłocznie. null, gdy nagranie nie jest dostępne |
recording_stopped_at | timestamp | null | Moment zatrzymania nagrywania przed zakończeniem połączenia; nic po tym momencie nie znajduje się w nagraniu. null, gdy nagrywanie trwało do końca połączenia lub połączenie nie było nagrywane |
recording_stopped_reason | string | null | Powód wcześniejszego zatrzymania nagrywania. warm_transfer: nagrywanie zatrzymuje się tuż przed tym, jak transfer konsultowany połączy się z numerem docelowym, i pozostaje wyłączone, nawet jeśli przekazanie się nie powiedzie, a rozmówca wróci do agenta. Inne wartości traktuj jako informacyjne; mogą zostać dodane kolejne. null, gdy recording_stopped_at ma wartość null |
billable_minutes | number | Minuty rozliczeniowe, zaokrąglone do najbliższej ćwierć minuty (co 15 sekund, minimum 0.25). Połączenia bezpośrednio z pocztą głosową nadal zgłaszają tutaj rzeczywistą liczbę naliczonych minut, 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 |
summary | string | null | Podsumowanie połączenia prostym językiem (od dwóch do czterech zdań prozy, maksymalnie 2,000 znaków, napisane w języku połączenia: podstawowym języku agenta, chyba że połączenie go zastąpiło), gdy agent ma włączoną opcję Generowanie podsumowania rozmowy w sekcji Dane do zebrania w kreatorze agenta (call_summary_enabled w obiekcie Agent) i generowanie się powiodło; w przeciwnym razie null, w tym dla połączeń zbyt krótkich do podsumowania, połączeń bez transkrypcji oraz agentów z wyłączonym nagrywaniem. Podsumowanie jest generowane w tym samym kroku po połączeniu co ekstrakcja, dlatego ustawienie Czekaj na wyodrębnione dane przed wysłaniem webhooka zakończenia połączenia (extraction_blocks_completion) określa, czy to zdarzenie na nie czeka: w trybie oczekiwania podsumowanie jest gotowe przed wysłaniem tego zdarzenia (samo podsumowanie ma maksymalnie 30 sekund w ramach 75-sekundowego limitu kroku); gdy zdarzenie nie czeka (never lub przekazanie w ramach except_transfers), to pole ma wartość null, a podsumowanie nie jest wysyłane ponownie w call.data_extracted, więc odczytaj je później z pola summary w obiekcie Call. Jest również dostępne na stronie połączenia w panelu |
extracted_data | object | null | Wynik ustrukturyzowanej ekstrakcji z polami status, fields, evidence, verification, field_reasons i schema_version. Każde pole inne niż null zawiera dokładny cytat sprawdzony strukturalnie (maksymalnie 1,000 znaków), a także rolę mówcy i indeks tury; dłuższe cytaty zwrócone przez model są odrzucane zamiast skracane. Dowód ma wartość null, gdy pole ma wartość null. verified oznacza, że każdy kandydat otrzymał dokładnie jeden prawidłowy niezależny werdykt. unavailable obejmuje również nieprawidłowe lub częściowe dane wyjściowe weryfikatora; prawidłowe częściowe werdykty nadal są stosowane, natomiast kandydaci bez jednego prawidłowego werdyktu otrzymują wartość null. status ma wartość completed, failed, exhausted, skipped lub skipped_recording_disabled; null, gdy agent nie miał pól ekstrakcji |
steps_trace | array | Tylko w połączeniach, których agent ma kroki lub przekazania. Kolejność, w jakiej połączenie przez nie przechodziło: każdy wpis zawiera t_ms (milisekundy od rozpoczęcia połączenia), agent (klucz agenta, a oraz jego ID), step (ID kroku lub null przy przekazaniu do agenta bez kroków) oraz via (start, model, condition, on_enter lub handoff). Zawiera tylko identyfikatory i czasy, dlatego jest zachowywane nawet przy wyłączonym nagrywaniu |
collected_values | object | Tylko w połączeniach, których agent ma kroki lub przekazania. Wartości zebrane przez agenta w jego krokach, z kluczem będącym nazwą pola zbierania, oraz <tool>_result dla wyniku on_enter_tool każdego kroku. Pomijane, gdy treść połączenia nie jest przechowywana (nagrywanie wyłączone) |
Powody zakończenia
| Wartość | Znaczenie |
|---|---|
user_hangup | Zdalna strona rozłączyła się jako pierwsza |
ai_hangup | AI celowo zakończyła połączenie |
ai_transfer | AI przekazała połączenie; ustawiono transfer_number |
ai_warm_transfer | AI zakończyła transfer z konsultacją |
voicemail_hangup | Wykryto pocztę głosową, a połączenie zakończono zgodnie z ustawieniem voicemail_action |
max_duration | Połączenie osiągnęło maksymalny limit czasu trwania: limit agenta, własny limit połączenia lub 6-godzinny limit platformy. Połączenie zakończone przez AI lub rozmówcę po ostrzeżeniu o limicie czasu zachowuje wartość ai_hangup lub user_hangup |
superseded | Sesja została zastąpiona nowszą |
inbound_config_webhook_failed | Połączenie przychodzące na numer w trybie webhooka: webhook konfiguracji nie zwrócił użytecznej konfiguracji, więc połączenie zakończono, zanim agent odebrał. status ma wartość failed, a połączenie nie jest rozliczane |
unknown | Nie można było ustalić przyczyny 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 | string | user, model, tool lub system |
content_type | string | text/plain dla mowy; application/json dla wywołań narzędzi, wyników narzędzi i zdarzeń systemowych |
content | string | object | Tekst mowy 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 | integer | Przesunięcia od rozpoczęcia połączenia w ms. Obecne, gdy znane jest czasowanie audio |
ttfa_ms | integer | Czas do pierwszego audio dla tury model, gdy jest mierzony |
audio_url, audio_urls | string / array | Wygasające podpisane adresy URL audio dla tury, gdy audio jest nagrywane dla każdej tury |
Aby uzyskać w pełni ustrukturyzowaną historię tur (ze znacznikami
przerwań, monitami potwierdzającymi i surowymi pozycjami), użyj
GET /v1/calls/{call_id}/history.
Różnice w starszym ładunku
Starsza koperta webhooka z jednym 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:
Starszy ładunek zakończenia zawiera również agent_id i agent_name.
- Tablica tur znajduje się w
history, a nie wtranscripts(ten sam schemat tury co powyżej). - Zestaw pól jest surowym raportem końca połączenia i może zawierać dodatkowe pola wewnętrzne wykraczające 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.completew 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 odpowiedzieć
{"transfer_ready": false}, aby zasygnalizować, że docelowy odbiorca 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ładowy handler
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 zastosowania
Zapisuj transkrypcję każdego połączenia i adres URL nagrania wraz z danymi klientów.
Przesyłaj strumieniowo transkrypcje do potoku w celu modelowania tematów, wyodrębniania sygnałów CSAT lub monitorowania wskaźnika przekazań.
Otwieraj połączenia w narzędziu QA do weryfikacji przez człowieka lub przetwarzaj je przez własny model oceny.
Powiadamiaj członka zespołu przy przekazaniu lub niepowodzeniu.