Open in
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 é disparado 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 no builder). 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 no builder) com a carga estável documentada abaixo, umevent_idpor entrega, um tempo limite de 30 s e tentativas por até 24 h. - O webhook legado de URL única recebe uma tentativa síncrona (tempo limite de 10 s, sem tentativas) com uma carga ligeiramente diferente — consulte Diferenças da carga legada.
Corpo 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",
"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_stopped_at": null,
"recording_stopped_reason": null,
"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"
}| Campo | Tipo | Descrição |
|---|---|---|
call_id | inteiro | Estável em todos os eventos desta chamada |
agent_id | inteiro | nulo | Agente que atendeu a chamada, quando um foi atribuído |
agent_name | string | nulo | Agente que atendeu a chamada, quando um foi atribuído |
direction | string | inbound, outbound, web, test. Cargas úteis históricas podem conter os valores legados mic ou widget |
from_number, to_number | string | E.164. from_number é o literal "web" para chamadas pela web e chamadas de teste |
origin_domain | string | Somente web/teste — a origem da página que hospedou o widget (vazia para sessões de microfone) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | inteiro | nulo | 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 |
variables | objeto | Variáveis de entrada registradas no início da chamada |
unresolved_variables | array | Nomes de variáveis referenciados pela configuração da chamada, mas não fornecidos no início da chamada |
transfer_number | string | nulo | Definido quando a chamada foi transferida |
recording_url | string | nulo | URL assinada com expiração; faça o download imediatamente. null quando não há artefato de gravação disponível |
recording_stopped_at | timestamp | nulo | Quando a gravação foi interrompida antes do fim da chamada; nada após esse momento está na gravação. null quando a gravação continuou até o fim da chamada ou a chamada não foi gravada |
recording_stopped_reason | string | nulo | Motivo pelo qual a gravação foi interrompida antecipadamente. warm_transfer: ela é interrompida pouco antes de uma transferência assistida ligar para o destino e permanece desativada mesmo se a transferência falhar e quem liga retornar ao agente. Considere outros valores apenas informativos; outros podem ser adicionados. null quando recording_stopped_at é null |
billable_minutes | número | Minutos cobrados, 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 | inteiro | Centavos de USD |
transcripts | array | Entradas de transcrição por turno; pode estar vazio quando uma transcrição não estiver disponível |
summary | string | nulo | Resumo pós-chamada em linguagem simples (de duas a quatro frases em prosa, com no máximo 2.000 caracteres, escrito no idioma da chamada: o idioma principal do agente, a menos que a chamada o tenha substituído) quando o agente tiver Gerar um resumo da chamada ativado em Dados a coletar no criador de agentes (call_summary_enabled no Agente) e a geração tiver sido bem-sucedida; caso contrário, null, inclusive para chamadas curtas demais para resumir, chamadas sem transcrição e agentes com a gravação desativada. O resumo é gerado na mesma etapa pós-chamada que a extração, portanto a configuração Aguardar dados extraídos antes de enviar o webhook de término da chamada (extraction_blocks_completion) determina se este evento aguarda por ele: em um modo de espera, o resumo fica pronto antes do envio deste evento (o próprio resumo recebe no máximo 30 segundos dentro do orçamento de 75 segundos da etapa); quando o evento não aguarda (never ou uma transferência em except_transfers), este campo é null e o resumo não é reenviado em call.data_extracted, portanto leia-o posteriormente em summary no objeto Call. Também disponível na página da chamada no dashboard |
extracted_data | objeto | nulo | Resultado de extração estruturada com status, fields, evidence, verification, field_reasons e schema_version. Cada campo não nulo possui a citação exata verificada estruturalmente (no máximo 1.000 caracteres), além da função de quem falou e do índice do turno; citações mais longas retornadas pelo modelo são rejeitadas em vez de truncadas. A evidência é null quando o campo é null. verified significa que cada candidato recebeu exatamente um veredito independente válido. unavailable também abrange saídas malformadas ou parciais do verificador; vereditos parciais válidos ainda se aplicam, enquanto candidatos sem um veredito válido são anulados. status é completed, failed, exhausted, skipped ou skipped_recording_disabled; null quando o agente não tinha campos de extração |
steps_trace | array | Somente em chamadas cujo agente tenha etapas ou transferências. A ordem em que a chamada passou por elas: cada entrada tem t_ms (milissegundos desde o início da chamada), agent (a chave do agente, a mais seu ID), step (o ID da etapa ou null em uma transferência para um agente sem etapas) e via (start, model, condition, on_enter ou handoff). Contém apenas IDs e horários, portanto é mantido mesmo quando a gravação está desativada |
collected_values | objeto | Somente em chamadas cujo agente tenha etapas ou transferências. Os valores que o agente coletou em suas etapas, indexados pelo nome do campo de coleta, além de <tool>_result para o resultado de on_enter_tool de cada etapa. Omitido quando o conteúdo da chamada não é retido (gravação desativada) |
Motivos de encerramento
| Valor | Significado |
|---|---|
user_hangup | A parte remota 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: o limite do agente, o limite da própria chamada ou o limite de 6 horas da plataforma. Uma chamada que a IA ou quem liga encerra após o aviso de limite de tempo mantém ai_hangup ou user_hangup |
superseded | A sessão foi substituída por uma mais recente |
inbound_config_webhook_failed | Chamada recebida para um número no modo webhook: o webhook de configuração não retornou uma configuração utilizável, portanto a chamada foi encerrada antes que um agente atendesse. status é failed e a chamada não é cobrada |
unknown | Não foi possível determinar o motivo do encerramento |
Formato da transcrição
Cada entrada em transcripts corresponde a um turno de 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 mudanças
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 da 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 de um turno 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 da carga útil legada
O envelope do webhook legado de URL única é
{"type": "telephony.complete" | "web.complete", "data": {…}}, sem
event_id, e seus data diferem da carga útil do endpoint:
A carga útil de conclusão legada também inclui agent_id e agent_name.
- O array de turnos fica em
history, não emtranscripts(mesmo esquema de turnos mostrado 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. - As chamadas de teste de microfone do Builder são reportadas como
telephony.completeno caminho legado (o sistema de endpoint 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 sinalizar 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 manipulador
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
Armazene a transcrição e a URL da gravação de cada chamada junto aos registros de clientes.
Envie transcrições em streaming para um pipeline de modelagem de tópicos, extração de sinais de CSAT ou monitoramento da taxa de transferências.
Abra chamadas em uma ferramenta de garantia de qualidade para revisão humana ou processe-as no seu próprio modelo de avaliação.
Acione um colega humano em caso de transferência ou falha.