telephony.complete / web.complete
Webhook non bloquant envoyé lorsqu’un appel se termine, avec transcription, URL d’enregistrement et métriques.
Un événement de complétion se déclenche après la fin de chaque appel — téléphonie entrante, téléphonie sortante, appel web ou appel de test (session micro du builder). Il est non bloquant : répondez avec n’importe quel code 2xx.
L’événement est envoyé par les deux canaux suivants :
- Les points de terminaison de webhook reçoivent
telephony.complete(appels téléphoniques) ouweb.complete(appels web et appels de test au micro du builder) avec la charge utile stable documentée ci-dessous, unevent_idpar livraison, un délai d’expiration de 30 s et des tentatives pendant jusqu’à 24 h. - Le webhook historique à URL unique reçoit une tentative synchrone unique (délai d’expiration de 10 s, sans nouvelle tentative) avec une charge utile légèrement différente — consultez Différences de charge utile historique.
Charge utile de la requête (livraisons aux points de terminaison)
{
"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"
}| Champ | Type | Description |
|---|---|---|
call_id | integer | Stable pour chaque événement de cet appel |
direction | string | inbound, outbound, web, test. Les charges utiles historiques peuvent contenir les anciennes valeurs mic ou widget |
from_number, to_number | string | E.164. from_number est littéralement "web" pour les appels web et les appels de test |
origin_domain | string | Web/test uniquement — l’origine de la page qui hébergeait le widget (vide pour les sessions micro) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | integer | null | Calculée à partir du début et de la fin |
status | string | completed ou failed |
end_reason | string | Consultez le tableau ci-dessous |
product, voice | string | Configuration de l’agent active au moment de l’appel |
transfer_number | string | null | Défini lorsque l’appel a été transféré |
recording_url | string | null | URL signée expirante ; téléchargez-la rapidement. null lorsqu’aucun artefact d’enregistrement n’est disponible |
billable_minutes | number | Minutes facturées, arrondies au quart de minute le plus proche (incréments de 15 secondes, minimum 0.25). Les appels allant directement sur messagerie vocale indiquent toujours ici leurs minutes réellement mesurées, mais le montant est plafonné à une minute au tarif du forfait. |
billing_total_cents | integer | Centimes USD |
transcripts | array | Entrées de transcription par tour ; peut être vide lorsqu’aucune transcription n’est disponible |
Raisons de fin
| Valeur | Signification |
|---|---|
user_hangup | L’interlocuteur distant a raccroché en premier |
ai_hangup | L’IA a délibérément mis fin à l’appel |
ai_transfer | L’IA a transféré l’appel ; transfer_number est défini |
ai_warm_transfer | L’IA a effectué un transfert supervisé |
voicemail_hangup | Une messagerie vocale a été détectée et l’appel a pris fin conformément à votre voicemail_action |
max_duration | L’appel a atteint la limite de durée maximale |
superseded | La session a été remplacée par une version plus récente |
unknown | La raison de fin n’a pas pu être déterminée |
Format de transcription
Chaque entrée de transcripts correspond à un tour de conversation. Les rôles sont
user (parole de l'appelant), model (parole de l'agent et appels d'outils),
tool (résultats d'outils) et system (événements d'appel tels que les changements
de langue).
[
{
"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"] }
}
}
]| Champ | Type | Description |
|---|---|---|
role | chaîne | user, model, tool ou system |
content_type | chaîne | text/plain pour la parole ; application/json pour les appels d'outils, les résultats d'outils et les événements système |
content | chaîne | objet | Texte de parole ou objet structuré présenté ci-dessus. Appels d'outils : {"tool_call": name, "arguments": {…}}. Résultats d'outils : {"tool_name": name, "response": {…}} |
start_ms, end_ms | entier | Décalages depuis le début de l'appel, en ms. Présents lorsque le timing audio est connu |
ttfa_ms | entier | Délai avant le premier audio pour un tour model, lorsqu'il est mesuré |
audio_url, audio_urls | chaîne / tableau | URL signées expirantes pour l'audio du tour, lorsqu'il est enregistré par tour |
Pour l'historique complet et structuré des tours (avec les marqueurs
d'interruption, les prompts d'acquiescement et les positions brutes), utilisez
GET /v1/calls/{call_id}/history.
Différences de payload héritées
L'enveloppe webhook héritée à URL unique est
{"type": "telephony.complete" | "web.complete", "data": {…}} sans
event_id, et son data diffère du payload de l'endpoint :
- Le tableau des tours se trouve sous
history, et non soustranscripts(même schéma de tour que ci-dessus). - L'ensemble de champs correspond au rapport brut de fin d'appel et peut inclure des champs internes supplémentaires au-delà du tableau ci-dessus — considérez les champs inconnus comme informatifs.
- Les appels web (
direction: "web") omettentfrom_number/to_numberet ajoutentorigin_domain. - Les appels de test du microphone dans le Builder sont signalés comme
telephony.completesur le chemin hérité (le système de l'endpoint les associe àweb.complete). - Coordination du transfert : lorsqu'un appel se termine par un transfert, le
webhook hérité est appelé de manière synchrone et peut répondre
{"transfer_ready": false}pour indiquer que la cible du transfert n'est pas prête. Toute autre réponse (ou l'absence de webhook hérité) permet au transfert de se poursuivre. Les livraisons par endpoint ne sont jamais consultées à cette fin.
Exemple de gestionnaire
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 });
},
);Cas d'utilisation courants
Enregistrez la transcription et l'URL d'enregistrement de chaque appel avec les données de vos clients.
Transmettez les transcriptions à un pipeline pour la modélisation des sujets, l'extraction de signaux CSAT ou le suivi du taux de transfert.
Ouvrez les appels dans un outil d'assurance qualité pour une révision humaine, ou exécutez-les dans votre propre modèle d'évaluation.
Alertez un membre de l'équipe humaine lors d'un transfert ou d'un échec.