Tema
Webhooks
Um webhook é o caminho contrário do envio: em vez de o seu sistema chamar o Konversia, é o Konversia que chama o seu sistema, em tempo real, quando algo acontece no atendimento. Este livro descreve o formato de cada entrega, para quem vai receber e processar esses eventos no seu servidor.
Se você procura como cadastrar um destino pela tela, quais eventos existem em português simples, e o que fazer quando um destino para de responder, veja Webhooks no guia do produto. Esta página assume que você já tem um destino cadastrado e um segredo em mãos.
O envelope
Toda entrega chega como um POST com um corpo JSON de cinco campos:
json
{
"id": "evt_01j8x8k9v1z3q7r6t5m4n2p0w9",
"type": "conversation.resolved",
"created_at": "2026-08-30T14:03:05+00:00",
"company_id": 42,
"data": {
"conversation_id": 918,
"contact_id": 331,
"status": "resolved"
}
}| Campo | Descrição |
|---|---|
id | Identificador único do evento, sempre com o prefixo evt_. Use para descartar entrega repetida, veja Entrega pelo menos uma vez |
type | Um dos dez valores da tabela abaixo |
created_at | Instante em que o evento aconteceu no Konversia, ISO 8601 |
company_id | Empresa dona do evento |
data | Conteúdo específico do tipo, ver a tabela abaixo |
Cabeçalhos
| Cabeçalho | Conteúdo |
|---|---|
X-Konversia-Signature | Assinatura da entrega, ver Conferir a assinatura |
X-Konversia-Event | O mesmo valor de type do corpo, para rotear sem precisar abrir o JSON |
X-Konversia-Delivery | O mesmo valor de id do corpo |
Os dez tipos de evento
Um destino só recebe os eventos marcados no cadastro dele; um mesmo evento pode ir para vários destinos.
| Tipo | Quando acontece | Campos em data |
|---|---|---|
message.received | Uma mensagem chegou do cliente final | message_id, conversation_id, contact_id, type, content, sent_at |
message.status_changed | O status de entrega de uma mensagem enviada mudou | message_id, conversation_id, client_temp_id, status, reason (sempre presente, vale null fora de failed/skipped) |
conversation.started | Uma conversa nova começou | conversation_id, contact_id, status |
conversation.assigned | Um atendente foi atribuído ou removido de uma conversa | conversation_id, contact_id, action (assigned ou unassigned), user (id, name) |
conversation.resolved | A conversa foi marcada como resolvida | conversation_id, contact_id, status |
conversation.reopened | Uma conversa resolvida voltou a ficar em aberto | conversation_id, contact_id, status |
contact.created | Um contato novo foi criado | contact_id, name, phone_number, email, is_blocked |
contact.updated | Nome, telefone, e-mail ou bloqueio de um contato mudou | contact_id, name, phone_number, email, is_blocked |
rating.recorded | Uma avaliação de atendimento foi registrada | conversation_id, contact_id, rated_user_id, rating, rated_at |
crm.stage_changed | Um cliente do CRM mudou de etapa no funil | crm_client_id, contact_id, from_stage_id, to_stage_id |
crm.stage_changed só é emitido para empresas com o módulo de CRM ligado.
contact.created não é emitido para contatos criados por importação de planilha: a importação grava os contatos em bloco, e esse caminho não passa pelo ponto que dispara o evento.
conversation.assigned é um delta, não um retrato
O payload traz só a mudança daquela linha (action e user), nunca a lista completa de quem está atribuído à conversa no momento. Um campo de "estado atual" poderia chegar fora de ordem e contradizer a realidade, porque a entrega entre eventos não é garantida em ordem, veja Sem garantia de ordem. Para saber quem está atribuído agora, acumule as entregas de conversation.assigned daquela conversa pelo action, ou consulte a tela.
Um evento que nasce de uma conversa (message.received, message.status_changed, conversation.started, conversation.assigned, conversation.resolved, conversation.reopened, rating.recorded) não é emitido quando essa conversa está marcada como privada (thread restrita a um usuário ou a um setor privado), nem sem conteúdo: o Konversia também não revela que existe um atendimento privado ali.
contact.created, contact.updated e crm.stage_changed não nascem de nenhuma conversa e por isso não passam por essa checagem: continuam sendo emitidos mesmo que a única conversa daquele contato seja privada.
Conferir a assinatura
O cabeçalho X-Konversia-Signature chega no formato:
t=1735689785,v1=5257a869e7bfce00...t: o instante do envio, em epoch (segundos).v1: HMAC SHA-256, em hexadecimal, calculado sobretmais o corpo cru, unidos por um ponto (<t>.<corpo cru>), com o segredo do destino como chave.
O instante entra no cálculo para que uma entrega capturada não possa ser reenviada indefinidamente: reenviar sem alterar t cai fora da janela de tolerância (300 segundos, o padrão) e é recusado; alterar t invalida a assinatura. Use sempre o corpo cru, exatamente como chegou na requisição, antes de qualquer parse: recodificar o JSON antes de calcular o HMAC pode mudar espaçamento ou ordem de campos e derrubar a comparação.
PHP
php
<?php
function verificarAssinatura(string $corpoCru, string $cabecalho, string $segredo, int $tolerancia = 300): bool
{
$partes = [];
foreach (explode(',', $cabecalho) as $pedaco) {
[$chave, $valor] = array_pad(explode('=', trim($pedaco), 2), 2, null);
if ($chave !== null && $valor !== null) {
$partes[$chave] = $valor;
}
}
$timestamp = $partes['t'] ?? null;
$assinaturaRecebida = $partes['v1'] ?? null;
if ($timestamp === null || $assinaturaRecebida === null || ! ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) >= $tolerancia) {
return false;
}
$assinaturaEsperada = hash_hmac('sha256', $timestamp.'.'.$corpoCru, $segredo);
return hash_equals($assinaturaEsperada, $assinaturaRecebida);
}
// Corpo cru, lido antes de qualquer parse de JSON.
$corpoCru = file_get_contents('php://input');
$cabecalho = $_SERVER['HTTP_X_KONVERSIA_SIGNATURE'] ?? '';
if (! verificarAssinatura($corpoCru, $cabecalho, $segredoDoDestino)) {
http_response_code(401);
exit;
}Node
js
const crypto = require('crypto');
function verificarAssinatura(corpoCru, cabecalho, segredo, tolerancia = 300) {
const partes = Object.fromEntries(
(cabecalho || '').split(',').map((pedaco) => pedaco.trim().split('='))
);
const timestamp = partes.t;
const assinaturaRecebida = partes.v1;
if (!timestamp || !assinaturaRecebida) {
return false;
}
const agora = Math.floor(Date.now() / 1000);
if (Math.abs(agora - Number(timestamp)) >= tolerancia) {
return false;
}
const assinaturaEsperada = crypto
.createHmac('sha256', segredo)
.update(`${timestamp}.${corpoCru}`)
.digest('hex');
const esperado = Buffer.from(assinaturaEsperada, 'utf8');
const recebido = Buffer.from(assinaturaRecebida, 'utf8');
return esperado.length === recebido.length && crypto.timingSafeEqual(esperado, recebido);
}
// Express, com o corpo cru capturado antes do parse de JSON.
app.post(
'/webhooks/konversia',
express.raw({ type: 'application/json' }),
(req, res) => {
const cabecalho = req.header('X-Konversia-Signature');
const corpoCru = req.body.toString('utf8');
if (!verificarAssinatura(corpoCru, cabecalho, process.env.KONVERSIA_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const evento = JSON.parse(corpoCru);
// processar evento.type / evento.data...
res.status(200).end();
}
);Responder à entrega
Qualquer status 2xx conta como sucesso. Qualquer outro status conta como falha e entra na retentativa, com uma exceção:
Redirecionamento conta como falha
A entrega é feita sem seguir redirecionamento: uma resposta 301, 302 ou qualquer outro 3xx conta como falha e consome uma tentativa da escala abaixo. Isso é proposital: seguir o desvio permitiria que o próprio destino apontasse para um endereço diferente depois que o Konversia já validou que a URL cadastrada é pública. Se o seu servidor normalmente redireciona (por exemplo, de http para https, ou entre domínios), cadastre diretamente o endereço final.
Responder 410 desliga o destino na hora
Um 410 Gone é tratado como "este endereço não existe mais" e desativa o destino imediatamente, sem esperar as seis tentativas. É a convenção que Zapier e Make devolvem quando o cenário do outro lado foi apagado: sem tratá-la, cada evento gastaria mais de oito horas de tentativas inúteis contra um endereço que já não existe.
A escala de espera entre uma tentativa e a próxima é 0 segundos, 1 minuto, 5 minutos, 30 minutos, 2 horas e 6 horas, seis tentativas ao todo, somando cerca de 9 horas (8 horas e 36 minutos) até a última. Se a última também falhar, aquela entrega não é tentada de novo automaticamente; use o reenvio manual na tela, descrito em Webhooks no guia do produto.
Entrega pelo menos uma vez
O Konversia garante que todo evento chega pelo menos uma vez, nunca "exatamente uma vez". Uma retentativa disparada depois de um tempo limite de rede pode acontecer depois que a entrega anterior já foi processada do seu lado com sucesso, e as duas chegam ao seu servidor.
Use o campo id do envelope para descartar repetição: antes de processar um evento, confira se aquele id já foi tratado (por exemplo, um registro numa tabela ou num cache com TTL de alguns dias) e ignore se já foi.
Sem garantia de ordem
Cada entrega roda como um job independente. Dois eventos da mesma conversa, disparados em sequência no Konversia, podem chegar ao seu servidor fora de ordem, por exemplo por causa de uma retentativa no meio do caminho ou de paralelismo na fila.
Quando a ordem importa para o seu processamento, compare created_at entre os eventos em vez de assumir a ordem de chegada.
Ligando com Zapier ou Make
Sem escrever código, o caminho que atende a maior parte dos clientes é apontar o destino para um webhook genérico do Zapier ou do Make: os dois recebem POST com corpo JSON diretamente, sem configuração extra do lado do Konversia.
Zapier
- Crie um Zap novo e escolha Webhooks by Zapier como app do gatilho, evento Catch Hook.
- Copie a URL gerada pelo Zapier.
- No Konversia, vá em Configurações, aba API e Integrações, seção Webhooks, e cadastre um destino novo com essa URL. Marque os eventos que esse Zap deve receber e guarde o segredo mostrado na hora, ele não aparece de novo.
- Provoque um evento de teste no Konversia (por exemplo, resolva uma conversa) e use Test trigger no Zapier para o Zap aprender o formato do payload.
- Continue o Zap normalmente, usando os campos de
datado evento nos passos seguintes.
Make
- Crie um cenário novo e adicione o módulo Webhooks, opção Custom webhook.
- Clique em Add para gerar um webhook novo e copie a URL.
- Cadastre essa URL como destino no Konversia, do mesmo jeito descrito acima para o Zapier.
- Rode Determine data structure no Make e provoque um evento real no Konversia para o Make aprender o formato do payload.
- Continue o cenário normalmente.
Conferir a assinatura dentro do próprio Zapier ou Make exige um passo de código (Code by Zapier, ou um módulo de função no Make) com a lógica dos exemplos de Conferir a assinatura. A maioria dos clientes pula essa checagem e confia na URL secreta gerada por esses serviços; para quem lida com dado sensível do outro lado, vale montar esse passo extra.
Relacionado
- Webhooks (guia do produto: como cadastrar um destino)
- Autenticação
- Erros
