Webhooks

.md

Webhooks de saída

Em vez de você perguntar “tem novidade?” de minuto em minuto, nós avisamos. Cada evento vira um POST assinado no endereço que você registrar.

Registrar

No painel, Configurações → Integrações → Novo webhook: uma URL https pública, uma descrição e os eventos que interessam. O segredo aparece ali — é com ele que você confere a assinatura. Se precisar dele depois, o menu do webhook tem “Revelar segredo”; e “Gerar novo segredo” troca o anterior na hora.

São até 10 webhooks por organização, e a URL precisa ser pública de verdade: endereço de rede interna é recusado no cadastro e na hora da entrega.

Comece pelo ping

O botão de avião ao lado de cada webhook manda um evento ping na hora e mostra o status HTTP que voltou. É a forma mais rápida de saber se a sua URL está de pé antes de esperar um evento de verdade.

O que chega

Todo envio é um POST com estes cabeçalhos:

  • X-LinkZap-Event — o nome do evento, para rotear sem abrir o corpo.
  • X-LinkZap-Delivery — o id desta entrega. É a sua chave de idempotência: a mesma entrega pode chegar duas vezes se a sua resposta se perder no caminho.
  • X-LinkZap-Signature — sha256= seguido do HMAC-SHA256 do corpo com o seu segredo.
  • User-Agent: LinkZap-Webhooks/1.
O corpo, sempre com esta formaJSON
{
  "id": "whd_8e31...",
  "event": "message.received",
  "data": {
    "conversationId": "cnv_5ad2...",
    "contactId": "ctt_3c81...",
    "channelId": "chn_9f2a...",
    "messageId": "msg_7bd0...",
    "phone": "5592999990000",
    "text": "Oi, queria marcar uma consulta",
    "kind": "text",
    "event": "message.received",
    "occurredAt": "2026-09-21T14:03:11.000Z"
  }
}

Verificar a assinatura

Sem verificar, qualquer um pode fingir ser nós

A sua URL é pública. O que distingue um evento nosso de um POST inventado por terceiros é a assinatura, e ela só vale se for conferida contra o corpo cru. Se o seu framework já converteu o JSON em objeto e você reserializa para conferir, a assinatura não vai bater — e a tentação de “simplificar” desligando a verificação é justamente o buraco.
Node.js (Express)JavaScript
// Express. O corpo CRU é obrigatório: JSON re-serializado não bate com a assinatura.
import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.LINKZAP_WEBHOOK_SECRET;

app.post("/linkzap", express.raw({ type: "application/json" }), (req, res) => {
  const assinatura = req.header("x-linkzap-signature") ?? "";
  const esperada = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");

  // timingSafeEqual exige o mesmo tamanho — compare os buffers com cuidado.
  const a = Buffer.from(assinatura);
  const b = Buffer.from(esperada);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  const { id, event, data } = JSON.parse(req.body.toString("utf8"));

  // Responda AGORA e processe depois: o prazo é de 10 segundos.
  res.sendStatus(200);
  enfileirar({ id, event, data });
});
PHPPHP
<?php
// O corpo cru, antes de qualquer json_decode.
$corpo = file_get_contents('php://input');
$assinatura = $_SERVER['HTTP_X_LINKZAP_SIGNATURE'] ?? '';
$esperada = 'sha256=' . hash_hmac('sha256', $corpo, getenv('LINKZAP_WEBHOOK_SECRET'));

if (!hash_equals($esperada, $assinatura)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($corpo, true);

// Responda antes de processar.
http_response_code(200);
flush();

enfileirar($evento['id'], $evento['event'], $evento['data']);
Python (Flask)Python
# Flask. request.data é o corpo cru.
import hmac, hashlib, os
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["LINKZAP_WEBHOOK_SECRET"].encode()

@app.post("/linkzap")
def linkzap():
    esperada = "sha256=" + hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(esperada, request.headers.get("X-LinkZap-Signature", "")):
        return "", 401

    evento = request.get_json()
    enfileirar(evento["id"], evento["event"], evento["data"])   # processe fora do pedido
    return "", 200

Use sempre uma comparação de tempo constante (timingSafeEqual, hash_equals, compare_digest) em vez de ==.

Como responder

  • Devolva 2xx assim que receber. Qualquer outro status, ou nenhuma resposta em 10 segundos, conta como falha. Não seguimos redirecionamento: uma resposta 3xx também conta como falha, então o endereço registrado tem de ser o final (já com https e com a barra do fim, se é para lá que o seu servidor redireciona).
  • Processe depois de responder. Enfileire e devolva 200; se o seu processamento demora, a entrega falha mesmo tendo chegado, e você recebe tudo de novo.
  • Trate repetição. Guarde o X-LinkZap-Delivery que você já processou. Não garantimos entrega única — garantimos que insistimos.
  • A ordem também não é garantida: dois eventos quase simultâneos podem chegar trocados. Use o occurredAt quando a ordem importar.

Quando falha

Insistimos 6 vezes, com espera crescente: 1 minuto, 5, 30, 2 horas e 12 horas. Depois da última, a entrega é marcada como desistida e não volta sozinha.

Na tela de Integrações, o menu de cada webhook tem Entregas: ali estão as últimas, com o corpo que saiu, o status que voltou, o erro e um botão para reenviar — inclusive as que já tinham desistido. É por onde se conserta um endereço que ficou fora do ar sem perder o que aconteceu no meio.

O histórico guarda 7 dias das entregas bem-sucedidas e 30 dias das que falharam ou desistiram.

Webhook desligado descarta

Enquanto a chave do webhook estiver desligada, as entregas que venceriam são marcadas como desistidas em vez de ficarem acumulando. Desligar não é pausar.

Os eventos

  • message.receivedMensagem recebida

    O contato mandou uma mensagem — o gatilho mais usado.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "message.received",
      "data": {
        "conversationId": "cnv_...",
        "contactId": "ctt_...",
        "channelId": "chn_...",
        "messageId": "msg_...",
        "phone": "5592999990000",
        "text": "Oi, queria marcar uma consulta",
        "kind": "text",
        "event": "message.received",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • message.sentMensagem enviada

    Saiu uma mensagem daqui — por uma pessoa da equipe, pela Aura, por um fluxo, por campanha ou pela própria API.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "message.sent",
      "data": {
        "conversationId": "cnv_...",
        "contactId": "ctt_...",
        "channelId": "chn_...",
        "messageId": "msg_...",
        "phone": "5592999990000",
        "text": "Claro! Que dia fica bom?",
        "author": "automation",
        "event": "message.sent",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • conversation.openedConversa aberta

    Uma conversa resolvida voltou para a fila.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "conversation.opened",
      "data": {
        "conversationId": "cnv_...",
        "contactId": "ctt_...",
        "channelId": "chn_...",
        "status": "open",
        "event": "conversation.opened",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • conversation.resolvedConversa resolvida

    O atendimento foi encerrado.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "conversation.resolved",
      "data": {
        "conversationId": "cnv_...",
        "contactId": "ctt_...",
        "channelId": "chn_...",
        "status": "resolved",
        "event": "conversation.resolved",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • conversation.assignedConversa atribuída

    A conversa passou a ter (ou deixou de ter) responsável.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "conversation.assigned",
      "data": {
        "conversationId": "cnv_...",
        "assignedTo": "usr_...",
        "by": "usr_...",
        "event": "conversation.assigned",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • conversation.department_changedConversa mudou de setor

    O setor PRINCIPAL da conversa mudou: ela foi encaminhada para outro time.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "conversation.department_changed",
      "data": {
        "conversationId": "cnv_...",
        "departmentId": "dpt_...",
        "by": "usr_...",
        "event": "conversation.department_changed",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • conversation.participants_changedParticipantes da conversa mudaram

    Alguém entrou ou saiu da conversa além do responsável. Vem a lista inteira que ficou, e o responsável do momento.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "conversation.participants_changed",
      "data": {
        "conversationId": "cnv_...",
        "assignedTo": "usr_...",
        "participants": [
          "usr_...",
          "usr_..."
        ],
        "by": "usr_...",
        "event": "conversation.participants_changed",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • conversation.departments_changedSetores adicionais da conversa mudaram

    Um setor foi adicionado ou tirado além do principal. Vem a lista inteira que ficou, e o setor principal do momento.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "conversation.departments_changed",
      "data": {
        "conversationId": "cnv_...",
        "departmentId": "dpt_...",
        "departmentIds": [
          "dpt_..."
        ],
        "by": "usr_...",
        "event": "conversation.departments_changed",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • contact.createdContato criado

    Um telefone novo entrou na base — por mensagem recebida, pela API, por importação ou pelo webhook de entrada.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "contact.created",
      "data": {
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "name": "Maria",
        "source": "whatsapp",
        "event": "contact.created",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • contact.opt_inContato deu opt-in

    O contato autorizou receber mensagens — é o que libera campanha e sequência.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "contact.opt_in",
      "data": {
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "source": "api",
        "event": "contact.opt_in",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • contact.opt_outContato pediu opt-out

    O contato pediu para não receber mais. Respeite no seu sistema também.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "contact.opt_out",
      "data": {
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "source": "api",
        "event": "contact.opt_out",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • contact.updatedContato alterado

    Mudou o nome, o telefone, o e-mail, um campo, uma etiqueta ou o responsável de um contato. changed diz o quê; releia o contato com GET /contacts?phone=. Uma mesma chamada pode gerar mais de um (um por tipo de mudança: nome, campos, etiquetas), e o de um contato recém-criado sai depois do contact.created dele. Opt-in e opt-out têm evento próprio.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "contact.updated",
      "data": {
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "name": "Maria Souza",
        "changed": [
          "fields"
        ],
        "event": "contact.updated",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • deal.createdNegócio criado

    Um negócio entrou no funil — por uma pessoa, por um fluxo, pela Aura ou pela API.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "deal.created",
      "data": {
        "dealId": "dea_...",
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "title": "Plano anual",
        "valueCents": 299000,
        "status": "open",
        "pipelineId": "ppl_...",
        "stageId": "stg_...",
        "stage": "Novo",
        "ownerId": "usr_...",
        "lostReason": null,
        "conversationId": "cnv_...",
        "event": "deal.created",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • deal.stage_changedNegócio mudou de etapa

    O negócio foi para outra etapa do funil. Vem a etapa nova e, em fromStageId/fromStage, a anterior. Reordenar dentro da mesma etapa não dispara.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "deal.stage_changed",
      "data": {
        "dealId": "dea_...",
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "title": "Plano anual",
        "valueCents": 299000,
        "status": "open",
        "pipelineId": "ppl_...",
        "stageId": "stg_...",
        "stage": "Proposta",
        "ownerId": "usr_...",
        "lostReason": null,
        "conversationId": "cnv_...",
        "fromStageId": "stg_...",
        "fromStage": "Em contato",
        "event": "deal.stage_changed",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • deal.wonNegócio ganho

    O negócio foi fechado como ganho.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "deal.won",
      "data": {
        "dealId": "dea_...",
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "title": "Plano anual",
        "valueCents": 299000,
        "status": "won",
        "pipelineId": "ppl_...",
        "stageId": "stg_...",
        "stage": "Negociação",
        "ownerId": "usr_...",
        "lostReason": null,
        "conversationId": "cnv_...",
        "event": "deal.won",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • deal.lostNegócio perdido

    O negócio foi fechado como perdido. lostReason traz o motivo.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "deal.lost",
      "data": {
        "dealId": "dea_...",
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "title": "Plano anual",
        "valueCents": 299000,
        "status": "lost",
        "pipelineId": "ppl_...",
        "stageId": "stg_...",
        "stage": "Proposta",
        "ownerId": "usr_...",
        "lostReason": "Preço",
        "conversationId": "cnv_...",
        "event": "deal.lost",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • task.createdTarefa criada

    Uma tarefa foi criada para a equipe. contactId e phone vêm nulos em tarefa que não é de um contato.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "task.created",
      "data": {
        "taskId": "tsk_...",
        "title": "Ligar de volta",
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "dealId": null,
        "assigneeId": "usr_...",
        "dueAt": "2026-10-08T02:59:59.000Z",
        "doneAt": null,
        "event": "task.created",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }
  • task.completedTarefa concluída

    Alguém concluiu a tarefa. Reabrir não dispara evento.

    Corpo entregueJSON
    {
      "id": "whd_...",
      "event": "task.completed",
      "data": {
        "taskId": "tsk_...",
        "title": "Ligar de volta",
        "contactId": "ctt_...",
        "phone": "5592999990000",
        "dealId": null,
        "assigneeId": "usr_...",
        "dueAt": "2026-10-08T02:59:59.000Z",
        "doneAt": "2026-10-07T14:10:00.000Z",
        "event": "task.completed",
        "occurredAt": "2026-09-21T14:03:11.000Z"
      }
    }