Skip to content

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"
  }
}
CampoDescrição
idIdentificador único do evento, sempre com o prefixo evt_. Use para descartar entrega repetida, veja Entrega pelo menos uma vez
typeUm dos dez valores da tabela abaixo
created_atInstante em que o evento aconteceu no Konversia, ISO 8601
company_idEmpresa dona do evento
dataConteúdo específico do tipo, ver a tabela abaixo

Cabeçalhos

CabeçalhoConteúdo
X-Konversia-SignatureAssinatura da entrega, ver Conferir a assinatura
X-Konversia-EventO mesmo valor de type do corpo, para rotear sem precisar abrir o JSON
X-Konversia-DeliveryO 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.

TipoQuando aconteceCampos em data
message.receivedUma mensagem chegou do cliente finalmessage_id, conversation_id, contact_id, type, content, sent_at
message.status_changedO status de entrega de uma mensagem enviada mudoumessage_id, conversation_id, client_temp_id, status, reason (sempre presente, vale null fora de failed/skipped)
conversation.startedUma conversa nova começouconversation_id, contact_id, status
conversation.assignedUm atendente foi atribuído ou removido de uma conversaconversation_id, contact_id, action (assigned ou unassigned), user (id, name)
conversation.resolvedA conversa foi marcada como resolvidaconversation_id, contact_id, status
conversation.reopenedUma conversa resolvida voltou a ficar em abertoconversation_id, contact_id, status
contact.createdUm contato novo foi criadocontact_id, name, phone_number, email, is_blocked
contact.updatedNome, telefone, e-mail ou bloqueio de um contato mudoucontact_id, name, phone_number, email, is_blocked
rating.recordedUma avaliação de atendimento foi registradaconversation_id, contact_id, rated_user_id, rating, rated_at
crm.stage_changedUm cliente do CRM mudou de etapa no funilcrm_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 sobre t mais 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

  1. Crie um Zap novo e escolha Webhooks by Zapier como app do gatilho, evento Catch Hook.
  2. Copie a URL gerada pelo Zapier.
  3. 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.
  4. 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.
  5. Continue o Zap normalmente, usando os campos de data do evento nos passos seguintes.

Make

  1. Crie um cenário novo e adicione o módulo Webhooks, opção Custom webhook.
  2. Clique em Add para gerar um webhook novo e copie a URL.
  3. Cadastre essa URL como destino no Konversia, do mesmo jeito descrito acima para o Zapier.
  4. Rode Determine data structure no Make e provoque um evento real no Konversia para o Make aprender o formato do payload.
  5. 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