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
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.
{
"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
// 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 });
});<?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']);# 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 "", 200Use 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
httpse 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-Deliveryque 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
occurredAtquando 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
Os eventos
message.receivedMensagem recebidaO 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 enviadaSaiu 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 abertaUma 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 resolvidaO 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ídaA 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 setorO 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 mudaramAlgué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 mudaramUm 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 criadoUm 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-inO 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-outO 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 alteradoMudou o nome, o telefone, o e-mail, um campo, uma etiqueta ou o responsável de um contato.
changeddiz o quê; releia o contato comGET /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 docontact.createddele. 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 criadoUm 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 etapaO 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 ganhoO 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 perdidoO negócio foi fechado como perdido.
lostReasontraz 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 criadaUma tarefa foi criada para a equipe.
contactIdephonevê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ídaAlgué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" } }