Pular para conteúdo

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

  1. No painel, acesse Webhooks.
  2. Clique em + Novo Webhook e informe um nome e a URL do seu endpoint. Use sempre uma URL HTTPS.
  3. 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çalho X-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_ended pode chegar antes de um participant_joined anterior que ainda estava em retentativa. Quando a ordem importa, reconcilie o estado consultando GET /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 failed e não é mais reenviada.

Consulta de entregas

Você pode inspecionar o histórico de entregas (útil para depurar uma integração) pelo endpoint:

GET https://api.videochamada.com.br/webhooks/deliveries

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-Id já 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.