ThunderPhone 2.0 já está no ar.Comece por conta própria, a partir de 2¢/min.Leia o anúncio

Webhooks

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:

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"
}
CampoTipoDescrição
call_idintegerEstável em todos os eventos desta chamada
directionstringinbound, outbound, web, test. Payloads históricos podem conter os valores legados mic ou widget
from_number, to_numberstringE.164. from_number é o valor literal "web" para chamadas web e chamadas de teste
origin_domainstringSomente web/teste — a origem da página que hospedava o widget (vazio para sessões de microfone)
start_time, end_timetimestampISO 8601 UTC
duration_secondsinteger | nullDerivado do início/fim
statusstringcompleted ou failed
end_reasonstringConsulte a tabela abaixo
product, voicestringConfiguração do agente em vigor no momento da chamada
transfer_numberstring | nullDefinido quando a chamada foi transferida
recording_urlstring | nullURL assinada com expiração; faça o download prontamente. null quando nenhum artefato de gravação estiver disponível
billable_minutesnumberMinutos 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_centsintegerCentavos de USD
transcriptsarrayEntradas de transcrição por turno; pode estar vazio quando uma transcrição não estiver disponível

Motivos de término

ValorSignificado
user_hangupA outra parte desligou primeiro
ai_hangupA IA encerrou a chamada deliberadamente
ai_transferA IA transferiu a chamada; transfer_number está definido
ai_warm_transferA IA concluiu uma transferência assistida
voicemail_hangupA caixa postal foi detectada e a chamada foi encerrada conforme sua voicemail_action
max_durationA chamada atingiu o limite máximo de duração
supersededA sessão foi substituída por uma mais recente
unknownNã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"] }
    }
  }
]
CampoTipoDescrição
rolestringuser, model, tool ou system
content_typestringtext/plain para fala; application/json para chamadas de ferramenta, resultados de ferramenta e eventos do sistema
contentstring | objectTexto 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_msintegerDeslocamentos desde o início da chamada, em ms. Presentes quando a temporização do áudio é conhecida
ttfa_msintegerTempo até o primeiro áudio para um turno de model, quando medido
audio_url, audio_urlsstring / arrayURLs 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 em transcripts (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") omitem from_number / to_number e adicionam origin_domain.
  • Chamadas de teste de microfone do Builder são reportadas como telephony.complete no caminho legado (o sistema de endpoints as mapeia para web.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

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

Casos de uso comuns

Integração com CRM

Persista a transcrição e a URL da gravação de cada chamada junto aos registros dos clientes.

Análises

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.

Revisão de qualidade

Abra chamadas em uma ferramenta de QA para revisão humana ou execute-as em seu próprio modelo de avaliação.

Notificações

Acione um integrante da equipe em caso de transferência ou falha.


Relacionados