ThunderPhone 2.0 jest już dostępny.Uruchom samodzielnie — od 2 centów/min.Przeczytaj komunikat

Webhooks

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:

Ł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"
}
PoleTypOpis
call_idintegerStałe dla każdego zdarzenia tego połączenia
directionstringinbound, outbound, web, test. Historyczne ładunki mogą zawierać starsze wartości mic lub widget
from_number, to_numberstringE.164. W przypadku połączeń internetowych i testowych from_number ma dosłowną wartość "web"
origin_domainstringTylko internet/test — źródło strony, na której hostowano widżet (puste dla sesji mikrofonowych)
start_time, end_timetimestampISO 8601 UTC
duration_secondsinteger | nullWyprowadzane na podstawie czasu rozpoczęcia i zakończenia
statusstringcompleted lub failed
end_reasonstringZobacz tabelę poniżej
product, voicestringKonfiguracja agenta obowiązująca w chwili połączenia
transfer_numberstring | nullUstawiane po przekazaniu połączenia
recording_urlstring | nullWygasający podpisany URL; pobierz niezwłocznie. null, gdy artefakt nagrania nie jest dostępny
billable_minutesnumberMinuty 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_centsintegerCenty USD
transcriptsarrayWpisy transkrypcji dla każdej tury; mogą być puste, gdy transkrypcja jest niedostępna

Powody zakończenia

WartośćZnaczenie
user_hangupRozmówca rozłączył się jako pierwszy
ai_hangupAI celowo zakończyła połączenie
ai_transferAI przekazała połączenie; ustawiono transfer_number
ai_warm_transferAI ukończyła transfer konsultowany
voicemail_hangupWykryto pocztę głosową, a połączenie zakończono zgodnie z voicemail_action
max_durationPołączenie osiągnęło maksymalny limit czasu trwania
supersededSesja została zastąpiona nowszą
unknownNie 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"] }
    }
  }
]
PoleTypOpis
roleciąg znakówuser, model, tool lub system
content_typeciąg znakówtext/plain dla wypowiedzi; application/json dla wywołań narzędzi, wyników narzędzi i zdarzeń systemowych
contentciąg znaków | obiektTekst 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_msliczba całkowitaPrzesunięcia względem początku połączenia w ms. Obecne, gdy znane jest czasowanie audio
ttfa_msliczba całkowitaCzas do pierwszego dźwięku dla tury model, gdy jest mierzony
audio_url, audio_urlsciąg znaków / tablicaWygasają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 w transcripts (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_number i dodają origin_domain.
  • Połączenia testowe mikrofonu w kreatorze są raportowane jako telephony.complete na starszej ścieżce (system punktu końcowego mapuje je na web.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

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 });
  },
);

Typowe przypadki użycia

Integracja z CRM

Zapisuj transkrypcję i adres URL nagrania każdego połączenia wraz z danymi klientów.

Analityka

Przesyłaj transkrypcje strumieniowo do potoku na potrzeby modelowania tematów, wyodrębniania sygnałów CSAT lub monitorowania współczynnika transferów.

Kontrola jakości

Otwieraj połączenia w narzędziu QA do ręcznej weryfikacji lub przetwarzaj je za pomocą własnego modelu oceny.

Powiadomienia

Powiadamiaj członka zespołu o transferze / błędzie.


Powiązane