Tema
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
2xxem 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"
}
}| Campo | Descrição |
|---|---|
id | Id único do evento. Use para deduplicar. Também vem no cabeçalho x-cavori-event-id. |
type | Tipo do evento (tabela abaixo). |
conversation_id | Conversa a que o evento pertence. |
message_id | Mensagem relacionada, ou null. |
sequence | Número do evento dentro da conversa, crescente. Use para ordenar. |
created_at | Quando o evento foi gerado (ISO 8601). |
data | Conteú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...e91ct é 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:
- Leia o corpo cru, antes de converter para JSON. Se você reserializar o JSON, os bytes mudam e a assinatura não confere.
- Separe
tev1do cabeçalho. - Recuse se
ttiver mais de 5 minutos de diferença do seu relógio. Isso impede que alguém reenvie uma entrega interceptada. - Calcule o HMAC e compare com
v1em tempo constante. - 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
sequencede cada conversa e não presuma que o evento 5 chega depois do 4. - Pode haver duplicatas. Deduplique pelo
iddo evento. - Responda rápido. Devolva
2xxassim 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/messagesdevolve todas as mensagens da conversa.
Sem URL de webhook, nada é entregue e as respostas só estão disponíveis por consulta.
