ThunderPhone 2.0 este acum disponibil.Îl configurați singur, de la 2 ¢/min.Citiți anunțul

Webhooks

telephony.complete / web.complete

Webhook neblocant transmis la încheierea unui apel, cu transcriere, URL-ul înregistrării și metrici.

Un eveniment de finalizare se declanșează după încheierea fiecărui apel — apel telefonic de intrare, apel telefonic de ieșire, apel web sau apel de test (sesiune cu microfonul din generator). Acesta este neblocant: răspundeți cu orice cod 2xx.

Evenimentul este livrat pe ambele căi:

Payloadul cererii (livrări către endpointuri)

{
  "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"
}
CâmpTipDescriere
call_idintegerStabil pentru fiecare eveniment al acestui apel
directionstringinbound, outbound, web, test. Payloadurile istorice pot conține valorile moștenite mic sau widget
from_number, to_numberstringE.164. from_number este literal "web" pentru apelurile web și apelurile de test
origin_domainstringNumai web/test — originea paginii care a găzduit widgetul (gol pentru sesiunile cu microfonul)
start_time, end_timetimestampISO 8601 UTC
duration_secondsinteger | nullDerivat din ora de început/încheiere
statusstringcompleted sau failed
end_reasonstringConsultați tabelul de mai jos
product, voicestringConfigurația agentului activă la momentul apelului
transfer_numberstring | nullSetat când apelul a fost transferat
recording_urlstring | nullURL semnat cu expirare; descărcați prompt. null când nu este disponibil niciun artefact de înregistrare
billable_minutesnumberMinute facturate, rotunjite la cel mai apropiat sfert de minut (incrementări de 15 secunde, minimum 0.25). Apelurile redirecționate direct către mesageria vocală raportează în continuare aici minutele lor efective contorizate, însă taxa este plafonată la un minut la tariful planului.
billing_total_centsintegerCenți USD
transcriptsarrayIntrări de transcriere pentru fiecare tură; pot fi goale când o transcriere nu este disponibilă

Motive de încheiere

ValoareSemnificație
user_hangupPartea de la distanță a închis prima
ai_hangupIA a încheiat apelul în mod deliberat
ai_transferIA a transferat apelul; transfer_number este setat
ai_warm_transferIA a finalizat un transfer asistat
voicemail_hangupA fost detectată mesageria vocală, iar apelul s-a încheiat conform voicemail_action
max_durationApelul a atins limita maximă de durată
supersededSesiunea a fost înlocuită de una mai nouă
unknownMotivul încheierii nu a putut fi determinat

Formatul transcrierii

Fiecare intrare din transcripts reprezintă un tur de conversație. Rolurile sunt user (vorbirea apelantului), model (vorbirea agentului și apelurile de instrumente), tool (rezultatele instrumentelor) și system (evenimente ale apelului, cum ar fi schimbările de limbă).

[
  {
    "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"] }
    }
  }
]
CâmpTipDescriere
roleșiruser, model, tool sau system
content_typeșirtext/plain pentru vorbire; application/json pentru apeluri de instrumente, rezultate ale instrumentelor și evenimente de sistem
contentșir | obiectTextul vorbirii sau obiectul structurat prezentat mai sus. Apeluri de instrumente: {"tool_call": name, "arguments": {…}}. Rezultate ale instrumentelor: {"tool_name": name, "response": {…}}
start_ms, end_msîntregDecalaje față de începutul apelului, în ms. Prezente când sincronizarea audio este cunoscută
ttfa_msîntregTimpul până la primul fragment audio pentru un tur model, când este măsurat
audio_url, audio_urlsșir / matriceURL-uri semnate cu expirare pentru conținutul audio al turului, când sunt înregistrate pentru fiecare tur

Pentru istoricul complet structurat al tururilor (cu marcaje de întrerupere, prompturi de confirmare și poziții brute), utilizați GET /v1/calls/{call_id}/history.

Diferențe ale încărcăturii utile moștenite

Învelișul webhook moștenit cu un singur URL este {"type": "telephony.complete" | "web.complete", "data": {…}}, fără event_id, iar data diferă de încărcătura utilă a endpointului:

  • Matricea de tururi se află în history, nu în transcripts (aceeași schemă de tur ca mai sus).
  • Setul de câmpuri reprezintă raportul brut de final de apel și poate include câmpuri interne suplimentare față de tabelul de mai sus — tratați câmpurile necunoscute ca informaționale.
  • Apelurile web (direction: "web") omit from_number / to_number și adaugă origin_domain.
  • Apelurile de testare a microfonului din Builder sunt raportate ca telephony.complete pe calea moștenită (sistemul endpoint le mapează la web.complete).
  • Coordonarea transferului: când un apel se încheie printr-un transfer, webhook-ul moștenit este apelat sincron și poate răspunde cu {"transfer_ready": false} pentru a semnala că ținta transferului nu este pregătită. Orice alt răspuns (sau lipsa unui webhook moștenit) permite continuarea transferului. Livrările către endpoint nu sunt niciodată consultate în acest scop.

Exemplu de handler

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

Cazuri de utilizare frecvente

Integrare CRM

Stocați transcrierea și URL-ul înregistrării fiecărui apel alături de datele clienților.

Analiză

Transmiteți transcrierile către un flux pentru modelarea subiectelor, extragerea semnalelor CSAT sau monitorizarea ratei de transfer.

Verificarea calității

Deschideți apelurile într-un instrument QA pentru verificare umană sau procesați-le prin propriul model de evaluare.

Notificări

Notificați un coleg uman în cazul unui transfer / eșec.


Asociate