Skip to content

Webhooks ​

Webhooks avisam o seu servidor quando algo acontece em uma conversa, sem que você precise consultar a API. São a única forma de receber a resposta de uma pessoa da equipe sem fazer polling, e também entregam as respostas do agente que não foram geradas por uma chamada sua (por exemplo, um follow-up).

Configurar ​

Na integração, em Configurações → Integrações, informe a URL do webhook. Requisitos:

  • HTTPS, em um endereço público;
  • sem usuário e senha na URL;
  • sem redirecionamento: a Cavori não segue redirects;
  • responda com status 2xx em até 10 segundos.

A Cavori mostra o segredo do webhook uma única vez, na criação ou quando a URL muda. Guarde-o: é com ele que você verifica a assinatura.

O que chega ​

Cada evento é um POST com Content-Type: application/json e o corpo abaixo:

json
{
  "id": "cm9x0evento0007",
  "type": "message.created",
  "conversation_id": "cm9x0conversa0001",
  "message_id": "cm9x0mensagem0002",
  "sequence": 4,
  "created_at": "2026-10-28T15:01:03.000Z",
  "data": {
    "message_id": "cm9x0mensagem0002",
    "content": "Abrimos sim, das 9h às 13h.",
    "sender": "AI"
  }
}
CampoDescrição
idId único do evento. Use para deduplicar. Também vem no cabeçalho x-cavori-event-id.
typeTipo do evento (tabela abaixo).
conversation_idConversa a que o evento pertence.
message_idMensagem relacionada, ou null.
sequenceNúmero do evento dentro da conversa, crescente. Use para ordenar.
created_atQuando o evento foi gerado (ISO 8601).
dataConteúdo específico do tipo.

Eventos ​

Os exemplos de data abaixo são ilustrativos.

message.received: a mensagem que a pessoa enviou foi recebida. Sai a cada POST /v1/chat/completions, inclusive quando a conversa está com uma pessoa da equipe.

json
{ "message_id": "cm9x0mensagem0001", "content": "Olá, vocês abrem no sábado?" }

message.created: uma mensagem foi criada na conversa. sender é AI para o agente e HUMAN para alguém da equipe. É por aqui que a resposta humana chega.

json
{ "message_id": "cm9x0mensagem0009", "content": "Oi, Ana! Aqui é o Pedro, vou te ajudar.", "sender": "HUMAN" }

Quando a resposta do agente foi interrompida por uma falha depois de parte do texto sair, o evento traz "partial": true com o trecho que saiu. Respostas de follow-up trazem follow_up_id.

conversation.updated: o atendimento mudou, por exemplo quando a conversa foi encaminhada para a equipe ou assumida por alguém. mode é AI, HUMAN_REQUESTED, HUMAN ou RESOLVED.

json
{ "mode": "HUMAN", "assigned_user_id": "cm9x0usuario0001", "assigned_team_id": null }

request.completed: uma execução terminou com sucesso.

json
{ "request_id": "cm9x0execucao0001", "message_id": "cm9x0mensagem0002", "finish_reason": "stop" }

request.failed: uma execução falhou.

json
{ "request_id": "cm9x0execucao0003" }

Verificar a assinatura ​

Toda entrega traz o cabeçalho:

text
x-cavori-signature: t=1793199663,v1=3f5a...e91c

t é o momento do envio, em segundos desde 1970. v1 é um HMAC-SHA256, em hexadecimal, calculado com o segredo do webhook sobre o texto <t>.<corpo exato>: o valor de t, um ponto e os bytes do corpo exatamente como chegaram.

Para verificar:

  1. Leia o corpo cru, antes de converter para JSON. Se você reserializar o JSON, os bytes mudam e a assinatura não confere.
  2. Separe t e v1 do cabeçalho.
  3. Recuse se t tiver mais de 5 minutos de diferença do seu relógio. Isso impede que alguém reenvie uma entrega interceptada.
  4. Calcule o HMAC e compare com v1 em tempo constante.
  5. Só então processe o evento.

O segredo entra no cálculo como texto, exatamente como a Cavori o mostrou: não o converta de hexadecimal para bytes. Os exemplos abaixo usam o corpo cru e devolvem true apenas quando a assinatura é válida e recente.

js
import { createHmac, timingSafeEqual } from 'node:crypto';

// `rawBody` é o corpo exatamente como chegou (Buffer ou string), antes de qualquer JSON.parse.
export function verificarAssinatura(rawBody, cabecalho, segredo, toleranciaSegundos = 300) {
  const partes = Object.fromEntries(
    String(cabecalho || '').split(',').map((parte) => parte.trim().split('=', 2)),
  );
  const t = Number(partes.t);
  if (!Number.isInteger(t) || !partes.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranciaSegundos) return false;

  const esperada = createHmac('sha256', segredo).update(`${t}.`).update(rawBody).digest('hex');
  const recebida = Buffer.from(partes.v1, 'hex');
  const calculada = Buffer.from(esperada, 'hex');
  return recebida.length === calculada.length && timingSafeEqual(recebida, calculada);
}
python
import hashlib
import hmac
import time


def verificar_assinatura(raw_body: bytes, cabecalho: str, segredo: str, tolerancia: int = 300) -> bool:
    """raw_body é o corpo exatamente como chegou, em bytes, antes de qualquer json.loads."""
    partes = {}
    for parte in (cabecalho or "").split(","):
        chave, _, valor = parte.strip().partition("=")
        partes[chave] = valor
    try:
        t = int(partes.get("t", ""))
    except ValueError:
        return False
    recebida = partes.get("v1", "")
    if not recebida or abs(time.time() - t) > tolerancia:
        return False

    mensagem = str(t).encode() + b"." + raw_body
    esperada = hmac.new(segredo.encode(), mensagem, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperada, recebida)
php
<?php
// $rawBody é o corpo exatamente como chegou: file_get_contents('php://input').
function verificarAssinatura(string $rawBody, string $cabecalho, string $segredo, int $tolerancia = 300): bool
{
    $partes = [];
    foreach (explode(',', $cabecalho) as $parte) {
        $par = explode('=', trim($parte), 2);
        if (count($par) === 2) {
            $partes[$par[0]] = $par[1];
        }
    }
    if (!isset($partes['t'], $partes['v1']) || !ctype_digit($partes['t'])) {
        return false;
    }
    $t = (int) $partes['t'];
    if (abs(time() - $t) > $tolerancia) {
        return false;
    }

    $esperada = hash_hmac('sha256', $t . '.' . $rawBody, $segredo);
    return hash_equals($esperada, $partes['v1']);
}

// Uso:
// $cabecalho = $_SERVER['HTTP_X_CAVORI_SIGNATURE'] ?? '';
// if (!verificarAssinatura(file_get_contents('php://input'), $cabecalho, getenv('CAVORI_WEBHOOK_SECRET'))) {
//     http_response_code(401);
//     exit;
// }

No Node, para obter o corpo cru em um servidor Express, use express.raw({ type: 'application/json' }) na rota do webhook em vez de express.json(). No Python, use request.get_data() (Flask) ou await request.body() (FastAPI).

Ordem, repetição e duplicatas ​

  • Os eventos podem chegar fora de ordem. Um evento que falha não segura os seguintes da mesma conversa, que continuam sendo entregues. Ordene pelo sequence de cada conversa e não presuma que o evento 5 chega depois do 4.
  • Pode haver duplicatas. Deduplique pelo id do evento.
  • Responda rápido. Devolva 2xx assim que gravar o evento e processe o resto depois. Qualquer outro status, ou mais de 10 segundos, conta como falha.
  • Repetição. Uma entrega que falha é repetida com espera crescente, de 5 segundos até 1 hora entre as tentativas, por até 24 horas desde que o evento foi gerado. Passado esse prazo o evento é descartado.
  • Recuperação. Se o seu servidor ficou fora do ar por mais de 24 horas, ou você perdeu eventos, reconstrua o estado pelo histórico: GET /v1/conversations/:id/messages devolve todas as mensagens da conversa.

Sem URL de webhook, nada é entregue e as respostas só estão disponíveis por consulta.