Configuração de Webhooks
Webhooks permitem que o seu sistema seja notificado em tempo real sobre o que acontece nas chamadas: criação, início, entrada e saída de participantes, quedas de conexão e encerramento. Em vez de consultar a API repetidamente (polling), você registra uma URL e a plataforma envia uma requisição POST com um payload JSON a cada evento.
Configurando Webhooks
- No painel, acesse Webhooks.
- Clique em + Novo Webhook e informe um nome e a URL do seu endpoint. Use sempre uma URL HTTPS.
- Opcionalmente, selecione os eventos que deseja receber. Um webhook sem filtro de eventos recebe todos os eventos do projeto.
Cada webhook pertence a um projeto e possui um secret próprio, exibido no painel, que é usado para assinar cada entrega (veja Verificação de assinatura). Você pode cadastrar vários webhooks no mesmo projeto — cada evento é entregue a todos os webhooks cujo filtro o inclua.
Duas convenções de nome de evento
A API usa duas convenções de nomenclatura ao mesmo tempo, e isso é intencional:
- Eventos de ciclo de vida da chamada usam ponto:
call.created,call.ended,call.reset. - Eventos de sessão (originados na sinalização em tempo real) usam underline:
call_started,call_ended,participant_joined,participant_left,participant_reconnected,disconnected.
O evento de início da chamada é call_started (com underline). call.started não existe — um webhook que filtra por call.started não recebe nada. Ao configurar filtros, use exatamente as strings listadas na tabela abaixo.
Eventos disponíveis
| Evento | Convenção | Quando dispara | Conteúdo de data |
|---|---|---|---|
call.created |
ponto | Uma chamada foi criada via API (POST /api/calls) |
{} |
call.ended |
ponto | A chamada foi encerrada com invalidação do link — por POST /api/calls/{id}/end ou por expiração automática (expiresAt). Em projetos com link reutilizável, a expiração dispara call.reset em vez de call.ended |
{} |
call.reset |
ponto | Em projetos com link reutilizável: a chamada foi encerrada e a sala foi liberada para reuso (o link continua válido) | {} |
call_started |
underline | A mídia foi conectada — a chamada efetivamente começou | {session, username, ip} |
call_ended |
underline | Evento bruto de encerramento registrado na sessão | {session, username, ip} |
participant_joined |
underline | Um participante entrou na chamada | {session, username, ip} |
participant_left |
underline | Um participante saiu da chamada voluntariamente | {session, username, ip} |
participant_reconnected |
underline | Um participante reconectou após queda de conexão | {session, username, ip} |
disconnected |
underline | Um participante perdeu a conexão inesperadamente | {session, username, ip} |
transcription.completed |
ponto | A transcrição de todas as sessões da chamada foi concluída — dispara uma vez por chamada, ao fim do pipeline de transcrição | {recordingId, transcriptionCount} |
Encerramento gera dois eventos
Ao encerrar uma chamada com invalidação do link, você recebe dois eventos: primeiro o evento bruto call_ended e em seguida o evento de ciclo de vida call.ended. Se você só precisa reagir uma vez ao encerramento, escolha um dos dois (em geral, call.ended).
Evento que nunca chega por webhook
O tipo participant_rejected (participante recusado porque a sala atingiu o limite de participantes) aparece na listagem de eventos da API (GET /api/calls/{id}/events), mas nunca é entregue via webhook.
Estrutura do payload
Todo evento é enviado como POST com corpo JSON no formato:
{
"event": "participant_joined",
"call": {
"id": "5f0e2b7c-3a1d-4c8e-9b6f-2d4a8c1e7f30",
"status": "active",
"created": "2025-02-03T12:00:00.000Z",
"started": "2025-02-03T12:05:00.000Z",
"ended": null,
"project": {
"id": "8a4b6c2d-1e3f-4a5b-8c7d-9e0f1a2b3c4d"
}
},
"data": {
"session": "session_abc123",
"username": "João Silva",
"ip": "203.0.113.10"
},
"timestamp": "2025-02-03T12:05:15.000Z",
"recording": {
"id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"
}
}
Descrição dos campos
| Campo | Descrição |
|---|---|
event |
Nome do evento (exatamente como na tabela acima) |
call.id |
Identificador único da chamada |
call.status |
Estado da chamada no momento em que a notificação foi montada: created, active ou ended. Em eventos de transição (por exemplo, o primeiro participant_joined), pode refletir o estado anterior à transição — use GET /api/calls/{id}/status como fonte de verdade |
call.created |
Data e hora de criação da chamada |
call.started |
Data e hora de início da chamada (null se ainda não iniciou) |
call.ended |
Data e hora de encerramento (null se ainda não encerrou) |
call.project.id |
Identificador do projeto associado |
data |
Dados específicos do evento: {} nos eventos de ciclo de vida (call.created, call.ended, call.reset) e {session, username, ip} nos eventos de sessão |
data.session |
Identificador único da sessão do participante |
data.username |
Nome do participante |
data.ip |
Endereço IP do participante |
timestamp |
Data e hora (ISO 8601) em que o evento foi processado |
recording.id |
Presente apenas quando a chamada possui gravação: identificador da gravação |
data.recordingId |
Apenas em transcription.completed: identificador da gravação transcrita |
data.transcriptionCount |
Apenas em transcription.completed: número de sessões transcritas na chamada |
Exemplos por evento
call.created
{
"event": "call.created",
"call": {
"id": "5f0e2b7c-3a1d-4c8e-9b6f-2d4a8c1e7f30",
"status": "created",
"created": "2025-02-03T12:00:00.000Z",
"started": null,
"ended": null,
"project": { "id": "8a4b6c2d-1e3f-4a5b-8c7d-9e0f1a2b3c4d" }
},
"data": {},
"timestamp": "2025-02-03T12:00:00.000Z"
}
call.ended
Disparado quando o link da chamada é invalidado — tanto pelo encerramento via API quanto pela expiração automática configurada em expiresAt. Exceção: em projetos com modo de link reutilizável, a expiração automática dispara call.reset (a sala é reiniciada, não invalidada).
{
"event": "call.ended",
"call": {
"id": "5f0e2b7c-3a1d-4c8e-9b6f-2d4a8c1e7f30",
"status": "ended",
"created": "2025-02-03T12:00:00.000Z",
"started": "2025-02-03T12:05:00.000Z",
"ended": "2025-02-03T12:30:00.000Z",
"project": { "id": "8a4b6c2d-1e3f-4a5b-8c7d-9e0f1a2b3c4d" }
},
"data": {},
"timestamp": "2025-02-03T12:30:00.000Z"
}
call.reset
Disparado apenas em projetos configurados com link reutilizável: a consulta terminou e a sala foi liberada, mas o link continua válido e a chamada não muda para ended.
{
"event": "call.reset",
"call": {
"id": "5f0e2b7c-3a1d-4c8e-9b6f-2d4a8c1e7f30",
"status": "active",
"created": "2025-02-03T12:00:00.000Z",
"started": "2025-02-03T12:05:00.000Z",
"ended": null,
"project": { "id": "8a4b6c2d-1e3f-4a5b-8c7d-9e0f1a2b3c4d" }
},
"data": {},
"timestamp": "2025-02-03T12:30:00.000Z"
}
call_started
Disparado quando a mídia é conectada — o momento em que a chamada efetivamente começa.
{
"event": "call_started",
"call": {
"id": "5f0e2b7c-3a1d-4c8e-9b6f-2d4a8c1e7f30",
"status": "active",
"created": "2025-02-03T12:00:00.000Z",
"started": "2025-02-03T12:05:00.000Z",
"ended": null,
"project": { "id": "8a4b6c2d-1e3f-4a5b-8c7d-9e0f1a2b3c4d" }
},
"data": {
"session": "session_abc123",
"username": "João Silva",
"ip": "203.0.113.10"
},
"timestamp": "2025-02-03T12:05:02.000Z"
}
Eventos de participante (participant_joined, participant_left, participant_reconnected, disconnected)
Todos seguem o mesmo formato; muda apenas o campo event:
{
"event": "participant_joined",
"call": {
"id": "5f0e2b7c-3a1d-4c8e-9b6f-2d4a8c1e7f30",
"status": "active",
"created": "2025-02-03T12:00:00.000Z",
"started": "2025-02-03T12:05:00.000Z",
"ended": null,
"project": { "id": "8a4b6c2d-1e3f-4a5b-8c7d-9e0f1a2b3c4d" }
},
"data": {
"session": "session_abc123",
"username": "João Silva",
"ip": "203.0.113.10"
},
"timestamp": "2025-02-03T12:05:15.000Z"
}
transcription.completed
Disparado uma vez por chamada, quando a transcrição de todas as sessões é concluída. Útil para buscar a transcrição pronta (por exemplo em GET /api/calls/{callId}/transcriptions/dialogue) sem ficar consultando a API. recording.id identifica a gravação transcrita e data.transcriptionCount traz o número de sessões transcritas.
{
"event": "transcription.completed",
"call": {
"id": "5f0e2b7c-3a1d-4c8e-9b6f-2d4a8c1e7f30",
"status": "ended",
"created": "2025-02-03T12:00:00.000Z",
"started": "2025-02-03T12:05:00.000Z",
"ended": "2025-02-03T12:30:00.000Z",
"project": { "id": "8a4b6c2d-1e3f-4a5b-8c7d-9e0f1a2b3c4d" }
},
"data": {
"recordingId": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"transcriptionCount": 2
},
"recording": {
"id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"
},
"timestamp": "2025-02-03T12:35:00.000Z"
}
Cabeçalhos enviados
Cada entrega inclui os seguintes cabeçalhos HTTP:
| Cabeçalho | Descrição |
|---|---|
Content-Type |
Sempre application/json |
X-Webhook-Event |
Nome do evento (o mesmo valor do campo event do payload) |
X-Webhook-Signature |
Assinatura HMAC-SHA256 do corpo da requisição, em hexadecimal, calculada com o secret do webhook |
X-Webhook-Id |
Identificador estável da entrega. Toda retentativa da mesma entrega repete exatamente o mesmo valor — use este cabeçalho para deduplicar |
Verificação de assinatura
Cada requisição é assinada com HMAC-SHA256 sobre os bytes brutos do corpo JSON, usando o secret do webhook como chave. Valide a assinatura antes de processar qualquer evento.
Use o corpo bruto, não o JSON re-serializado
Calcule o HMAC sobre o corpo exatamente como recebido na requisição. Se você fizer JSON.parse e serializar de novo, a ordem das chaves ou a formatação podem mudar e a assinatura deixará de bater.
Node.js
const crypto = require('crypto');
const express = require('express');
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // secret exibido no painel
function verificaAssinatura(corpoBruto, assinaturaRecebida) {
const esperada = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(corpoBruto) // Buffer com o corpo exatamente como recebido
.digest('hex');
const a = Buffer.from(esperada, 'utf8');
const b = Buffer.from(assinaturaRecebida || '', 'utf8');
// Comparação em tempo constante (evita timing attacks)
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw preserva o corpo bruto (Buffer) para o cálculo do HMAC
app.post(
'/webhooks/videochamada',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!verificaAssinatura(req.body, req.headers['x-webhook-signature'])) {
return res.status(401).send('assinatura inválida');
}
// Responda 200 imediatamente e processe de forma assíncrona
res.sendStatus(200);
const evento = JSON.parse(req.body.toString('utf8'));
const idEntrega = req.headers['x-webhook-id']; // use para deduplicar
// ... processe o evento ...
},
);
Python
import hashlib
import hmac
import json
import os
from flask import Flask, request
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"] # secret exibido no painel
app = Flask(__name__)
def verifica_assinatura(corpo_bruto: bytes, assinatura_recebida: str) -> bool:
esperada = hmac.new(
WEBHOOK_SECRET.encode("utf-8"), corpo_bruto, hashlib.sha256
).hexdigest()
# Comparação em tempo constante (evita timing attacks)
return hmac.compare_digest(esperada, assinatura_recebida or "")
@app.post("/webhooks/videochamada")
def webhook():
corpo_bruto = request.get_data() # bytes exatamente como recebidos
if not verifica_assinatura(
corpo_bruto, request.headers.get("X-Webhook-Signature", "")
):
return "assinatura inválida", 401
evento = json.loads(corpo_bruto)
id_entrega = request.headers.get("X-Webhook-Id") # use para deduplicar
# Enfileire o processamento e responda rápido
return "", 200
Semântica de entrega
Entenda estas garantias antes de integrar — elas definem como o seu endpoint deve se comportar:
- Entrega com retentativas (na prática, at-least-once). Cada notificação é persistida e reentregue com retentativas automáticas; a mesma entrega pode chegar mais de uma vez. Em condições extremas (falha de infraestrutura no exato momento do enfileiramento), uma notificação pode não ser entregue — para estados críticos, reconcilie periodicamente com
GET /api/calls/{id}/status. Deduplique pelo cabeçalhoX-Webhook-Id: retentativas da mesma entrega repetem o mesmo identificador e o mesmo corpo, byte a byte. - A ordem de chegada não é garantida. Entregas são feitas em paralelo e retentativas são reagendadas de forma independente — um
call_endedpode chegar antes de umparticipant_joinedanterior que ainda estava em retentativa. Quando a ordem importa, reconcilie o estado consultandoGET /api/calls/{id}/status, que é a fonte de verdade. - Sucesso = qualquer resposta 2xx dentro de aproximadamente 3 segundos. Responda rápido e processe o evento de forma assíncrona (fila, worker). Uma resposta lenta é tratada como falha e gera retentativa.
- Falhas são retentadas com backoff exponencial. O intervalo começa em torno de 1 minuto e dobra a cada falha, com teto de 1 hora, por até 12 tentativas no total. Esgotadas as tentativas, a entrega é marcada como
failede não é mais reenviada.
Consulta de entregas
Você pode inspecionar o histórico de entregas (útil para depurar uma integração) pelo endpoint:
Autenticação diferente dos demais endpoints
Este é um recurso de consulta técnica autenticado com a credencial de sessão do painel (JWT) — a mesma usada ao navegar no painel —, e não com a API key do projeto. Os resultados são sempre restritos aos projetos da sua conta.
Parâmetros de consulta (todos opcionais):
| Parâmetro | Descrição |
|---|---|
projectId |
Filtra por projeto |
callId |
Filtra por chamada |
webhookId |
Filtra por webhook |
status |
Filtra por situação: pending, delivering, delivered ou failed |
limit |
Quantidade de registros (padrão 100, máximo 500) |
Campos principais de cada entrega:
| Campo | Descrição |
|---|---|
id |
Identificador da entrega (o mesmo valor enviado em X-Webhook-Id) |
webhookId / projectId / callId |
Identificadores do webhook, projeto e chamada |
eventType |
Nome do evento enviado |
url |
URL de destino no momento do envio |
status |
pending (aguardando tentativa), delivering (tentativa em andamento), delivered (entregue, 2xx recebido) ou failed (tentativas esgotadas) |
attempts / maxAttempts |
Tentativas realizadas e limite de tentativas |
nextAttemptAt |
Quando a próxima tentativa está agendada |
lastAttemptedAt |
Quando foi a última tentativa |
responseStatus |
Código HTTP da última resposta do seu endpoint |
lastError |
Descrição do último erro, quando houver |
created / updated |
Datas de criação e atualização do registro |
Registros em estado terminal (delivered ou failed) são mantidos por cerca de 30 dias e depois removidos.
Boas práticas de segurança
- Valide a assinatura de toda requisição com o secret do webhook, usando comparação em tempo constante. Rejeite requisições com assinatura ausente ou inválida.
- Responda 2xx rapidamente e processe o evento de forma assíncrona. O processamento pesado dentro do handler causa timeout e retentativas desnecessárias.
- Torne o processamento idempotente. Como a entrega é at-least-once, guarde os
X-Webhook-Idjá processados e ignore repetições. - Use apenas HTTPS no endpoint do webhook. O payload contém dados de chamadas e participantes.
- Não confie na ordem dos eventos. Para decisões que dependem do estado atual da chamada, consulte
GET /api/calls/{id}/status.