telephony.complete / web.complete
Webhook não bloqueante entregue quando uma chamada termina, com transcrição, URL da gravação e métricas.
Um evento de conclusão é acionado após o término de cada chamada — telefonia de entrada, telefonia de saída, chamada web ou chamada de teste (sessão de microfone do construtor). Ele é não bloqueante: responda com qualquer código 2xx.
O evento é entregue por ambos os caminhos:
- Endpoints de webhook recebem
telephony.complete(chamadas telefônicas) ouweb.complete(chamadas web e chamadas de teste de microfone do construtor) com o payload estável documentado abaixo, umevent_idpor entrega, um tempo limite de 30 s e novas tentativas por até 24 h. - O webhook legado de URL única recebe uma tentativa síncrona (tempo limite de 10 s, sem novas tentativas) com um payload ligeiramente diferente — consulte Diferenças do payload legado.
Payload da solicitação (entregas de 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",
"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"
}| Campo | Tipo | Descrição |
|---|---|---|
call_id | integer | Estável em todos os eventos desta chamada |
direction | string | inbound, outbound, web, test. Payloads históricos podem conter os valores legados mic ou widget |
from_number, to_number | string | E.164. from_number é o valor literal "web" para chamadas web e chamadas de teste |
origin_domain | string | Somente web/teste — a origem da página que hospedava o widget (vazio para sessões de microfone) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | integer | null | Derivado do início/fim |
status | string | completed ou failed |
end_reason | string | Consulte a tabela abaixo |
product, voice | string | Configuração do agente em vigor no momento da chamada |
transfer_number | string | null | Definido quando a chamada foi transferida |
recording_url | string | null | URL assinada com expiração; faça o download prontamente. null quando nenhum artefato de gravação estiver disponível |
billable_minutes | number | Minutos faturados, arredondados para o quarto de minuto mais próximo (incrementos de 15 segundos, mínimo de 0.25). Chamadas que vão diretamente para a caixa postal ainda informam aqui seus minutos reais medidos, mas a cobrança é limitada a um minuto pela tarifa do plano. |
billing_total_cents | integer | Centavos de USD |
transcripts | array | Entradas de transcrição por turno; pode estar vazio quando uma transcrição não estiver disponível |
Motivos de término
| Valor | Significado |
|---|---|
user_hangup | A outra parte desligou primeiro |
ai_hangup | A IA encerrou a chamada deliberadamente |
ai_transfer | A IA transferiu a chamada; transfer_number está definido |
ai_warm_transfer | A IA concluiu uma transferência assistida |
voicemail_hangup | A caixa postal foi detectada e a chamada foi encerrada conforme sua voicemail_action |
max_duration | A chamada atingiu o limite máximo de duração |
superseded | A sessão foi substituída por uma mais recente |
unknown | Não foi possível determinar o motivo do término |
Formato da transcrição
Cada entrada em transcripts é um turno da conversa. Os papéis são
user (fala de quem liga), model (fala do agente e chamadas de ferramenta),
tool (resultados de ferramenta) e system (eventos da chamada, como trocas
de idioma).
[
{
"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"] }
}
}
]| Campo | Tipo | Descrição |
|---|---|---|
role | string | user, model, tool ou system |
content_type | string | text/plain para fala; application/json para chamadas de ferramenta, resultados de ferramenta e eventos do sistema |
content | string | object | Texto de fala ou o objeto estruturado mostrado acima. Chamadas de ferramenta: {"tool_call": name, "arguments": {…}}. Resultados de ferramenta: {"tool_name": name, "response": {…}} |
start_ms, end_ms | integer | Deslocamentos desde o início da chamada, em ms. Presentes quando a temporização do áudio é conhecida |
ttfa_ms | integer | Tempo até o primeiro áudio para um turno de model, quando medido |
audio_url, audio_urls | string / array | URLs assinadas com expiração para o áudio do turno, quando gravado por turno |
Para o histórico de turnos totalmente estruturado (com marcadores de
interrupção, prompts de confirmação e posições brutas), use
GET /v1/calls/{call_id}/history.
Diferenças do payload legado
O envelope legado do webhook com URL única é
{"type": "telephony.complete" | "web.complete", "data": {…}}, sem
event_id, e seu data difere do payload do endpoint:
- O array de turnos fica em
history, não emtranscripts(mesmo esquema de turnos acima). - O conjunto de campos é o relatório bruto de fim de chamada e pode incluir campos internos adicionais além da tabela acima — trate campos desconhecidos como informativos.
- Chamadas web (
direction: "web") omitemfrom_number/to_numbere adicionamorigin_domain. - Chamadas de teste de microfone do Builder são reportadas como
telephony.completeno caminho legado (o sistema de endpoints as mapeia paraweb.complete). - Coordenação de transferência: quando uma chamada termina em uma transferência,
o webhook legado é chamado de forma síncrona e pode responder
{"transfer_ready": false}para indicar que o destino da transferência não está pronto. Qualquer outra resposta (ou nenhum webhook legado) permite que a transferência prossiga. As entregas do endpoint nunca são consultadas para isso.
Exemplo de 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 });
},
);Casos de uso comuns
Persista a transcrição e a URL da gravação de cada chamada junto aos registros dos clientes.
Envie transcrições por streaming para um pipeline de modelagem de tópicos, extração de sinais de CSAT ou monitoramento da taxa de transferência.
Abra chamadas em uma ferramenta de QA para revisão humana ou execute-as em seu próprio modelo de avaliação.
Acione um integrante da equipe em caso de transferência ou falha.