Open in
telephony.complete / web.complete
Webhook non bloquant envoyé à la fin d’un appel, avec transcription, URL de l’enregistrement et métriques.
Un événement de fin est déclenché après 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é sur les deux chemins :
- Les points de terminaison 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, aucune nouvelle tentative) avec une charge utile légèrement différente — consultez Différences de charge utile historique.
Charge utile de requête (livraisons d’endpoint)
{
"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_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"
}| Champ | Type | Description |
|---|---|---|
call_id | integer | Stable pour chaque événement de cet appel |
agent_id | integer | null | Agent qui a traité l’appel, lorsqu’un agent était attribué |
agent_name | string | null | Agent qui a traité l’appel, lorsqu’un agent était attribué |
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 | Dérivé du début et de la fin |
status | string | completed ou failed |
end_reason | string | Voir le tableau ci-dessous |
product, voice | string | Configuration de l’agent active au moment de l’appel |
variables | object | Variables d’entrée capturées au démarrage de l’appel |
unresolved_variables | array | Noms des variables référencées par la configuration de l’appel, mais non fournies au démarrage 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 rapidement. null lorsqu’aucun enregistrement n’est disponible |
billable_minutes | number | Minutes facturées, arrondies au quart de minute le plus proche (incréments de 15 secondes, minimum de 0.25). Les appels dirigés directement vers la messagerie vocale indiquent toujours ici leurs minutes réellement comptabilisées, mais la facturation est plafonnée à une minute au tarif du forfait. |
billing_total_cents | integer | Centimes USD |
transcripts | array | Entrées de transcription par tour ; peut être vide lorsqu’une transcription n’est pas disponible |
summary | string | null | Résumé post-appel en langage naturel (deux à quatre phrases, au maximum 2 000 caractères, rédigé dans la langue de l’appel : la langue principale de l’agent, sauf si l’appel l’a remplacée) lorsque l’agent a activé Générer un résumé d’appel sous Données à collecter dans le générateur d’agent (call_summary_enabled sur l’Agent) et que la génération a réussi ; null dans le cas contraire, y compris pour les appels trop courts pour être résumés, les appels sans transcription et les agents dont l’enregistrement est désactivé. Le résumé est généré à la même étape post-appel que l’extraction ; le paramètre Attendre les données extraites avant d’envoyer le webhook de fin d’appel (extraction_blocks_completion) détermine donc si cet événement l’attend : dans un mode d’attente, le résumé est prêt avant l’envoi de cet événement (le résumé lui-même dispose d’au maximum 30 secondes dans le budget de 75 secondes de l’étape) ; lorsque l’événement n’attend pas (never, ou un transfert sous except_transfers), ce champ est null et le résumé n’est pas renvoyé dans call.data_extracted, consultez-le donc ultérieurement via summary sur l’objet Call. Également disponible sur la page d’appel dans le dashboard |
extracted_data | object | null | Résultat d’extraction structuré avec status, fields, evidence, verification, field_reasons et schema_version. Chaque champ non nul contient la citation exacte vérifiée structurellement (au maximum 1 000 caractères), ainsi que le rôle du locuteur et l’index du tour ; les citations plus longues renvoyées par le modèle sont rejetées au lieu d’être tronquées. La preuve est null lorsque le champ est null. verified signifie que chaque candidat a reçu exactement un verdict indépendant valide. unavailable couvre également les sorties de vérificateur mal formées ou partielles ; les verdicts partiels valides sont toujours appliqués, tandis que les candidats sans verdict valide sont définis sur null. status est completed, failed, exhausted, skipped ou skipped_recording_disabled ; null lorsque l’agent n’avait aucun champ d’extraction |
Raisons de fin
| Valeur | Signification |
|---|---|
user_hangup | Le correspondant 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 assisté |
voicemail_hangup | Une messagerie vocale a été détectée et l’appel s’est terminé conformément à votre voicemail_action |
max_duration | L’appel a atteint la durée maximale autorisée |
superseded | La session a été remplacée par une session 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 verbal et les positions brutes), utilisez
GET /v1/calls/{call_id}/history.
Différences de charge utile héritée
L’enveloppe webhook héritée à URL unique est
{"type": "telephony.complete" | "web.complete", "data": {…}} sans
event_id, et ses data diffèrent de la charge utile de l’endpoint :
La charge utile de fin héritée contient également agent_id et agent_name.
- Le tableau des tours se trouve sous
history, et non soustranscripts(même schéma de tour que ci-dessus). - L’ensemble des champs correspond au rapport brut de fin d’appel et peut inclure des champs internes supplémentaires au-delà du tableau ci-dessus : traitez les champs inconnus comme des informations.
- Les appels web (
direction: "web") omettentfrom_number/to_numberet ajoutentorigin_domain. - Les appels de test du micro dans le Builder sont signalés comme
telephony.completesur le chemin hérité (le système de l’endpoint les mappe versweb.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 via l’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 vos dossiers clients.
Envoyez les transcriptions vers un pipeline pour la modélisation de 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 analysez-les avec votre propre modèle d'évaluation.
Alertez un membre de l'équipe lors d'un transfert ou d'un échec.