---
title: "Webhooks de saída"
slug: webhooks
group: "Webhooks"
source: https://linkzap.orion.moe/developers/webhooks
openapi: https://linkzap.orion.moe/openapi.json
---

# 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.

> [!NOTE]
> **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`.

Exemplo — O corpo, sempre com esta forma:

```json
{
  "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

> [!CAUTION]
> **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.

Trecho — Node.js (Express):

```js
// 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 });
});
```

Trecho — PHP:

```php
<?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']);
```

Trecho — 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.

> [!WARNING]
> **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.received` — Mensagem recebida

O contato mandou uma mensagem — o gatilho mais usado.

```json
{
  "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.sent` — Mensagem enviada

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

```json
{
  "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.opened` — Conversa aberta

Uma conversa resolvida voltou para a fila.

```json
{
  "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.resolved` — Conversa resolvida

O atendimento foi encerrado.

```json
{
  "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.assigned` — Conversa atribuída

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

```json
{
  "id": "whd_...",
  "event": "conversation.assigned",
  "data": {
    "conversationId": "cnv_...",
    "assignedTo": "usr_...",
    "by": "usr_...",
    "event": "conversation.assigned",
    "occurredAt": "2026-09-21T14:03:11.000Z"
  }
}
```

### `conversation.department_changed` — Conversa mudou de setor

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

```json
{
  "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_changed` — Participantes 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.

```json
{
  "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_changed` — Setores 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.

```json
{
  "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.created` — Contato criado

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

```json
{
  "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_in` — Contato deu opt-in

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

```json
{
  "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_out` — Contato pediu opt-out

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

```json
{
  "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.updated` — Contato 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.

```json
{
  "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.created` — Negócio criado

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

```json
{
  "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_changed` — Negó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.

```json
{
  "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.won` — Negócio ganho

O negócio foi fechado como ganho.

```json
{
  "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.lost` — Negócio perdido

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

```json
{
  "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.created` — Tarefa criada

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

```json
{
  "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.completed` — Tarefa concluída

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

```json
{
  "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"
  }
}
```

### `ping` — Teste de entrega

Você apertou o botão de avião na tela de Integrações. Não corresponde a nada que tenha acontecido na conta — serve para conferir que a sua URL responde e que a assinatura confere.

```json
{
  "id": "whd_...",
  "event": "ping",
  "data": {
    "by": "voce@exemplo.com",
    "event": "ping",
    "occurredAt": "2026-09-21T14:03:11.000Z"
  }
}
```
