VendeeDocs

Webhooks

Receba eventos do CRM em tempo real na sua aplicação, com verificação de assinatura HMAC e retry automático.

Os webhooks permitem que o Vendee notifique a sua aplicação quando algo acontece no CRM, sem você precisar ficar consultando a API. Você registra uma URL de destino (HTTPS, pública) e o CRM envia um POST para ela a cada evento assinado que sua assinatura cobre.

Webhooks fazem parte da API Pública — seguem o mesmo escopo de plano dela (veja Chaves e escopos). Como qualquer integração nova, teste contra o ambiente de testes antes de depender do fluxo em produção.

Catálogo de eventos

EventoQuando dispara
deal.stage_changedUm negócio muda de etapa no pipeline.
deal.wonUm negócio é marcado como ganho.
deal.lostUm negócio é marcado como perdido.
deal.staleUm negócio fica parado N dias sem movimento (verificação horária).
activity.createdUma atividade é criada.
activity.overdueUma atividade passa da data prevista sem ser concluída.
proposal.viewedUma proposta é aberta pela primeira vez pelo destinatário.
deal.createdUm negócio é criado (pelo dashboard ou via POST /api-v1-deals).
contact.createdUm contato é criado (pelo dashboard ou via POST /api-v1-contacts).
lead.receivedUm lead é recebido via POST /api-v1-leads e um negócio novo é criado a partir dele. Reenvios idempotentes (mesmo external_id) não disparam o evento de novo.
proposal.acceptedUma proposta é aceita pelo cliente através do link público.
proposal.rejectedUma proposta é recusada pelo cliente através do link público.

Cadastrar uma assinatura

Uma API Key com o escopo webhooks:manage cria a assinatura:

curl -X POST "{BASE_URL}/api-v1-webhooks" \
  -H "X-API-Key: vnd_sua_chave_aqui" \
  -H "Content-Type: application/json" \
  -d '{
    "event_types": ["deal.created", "lead.received"],
    "target_url": "https://sua-aplicacao.com/webhooks/vendee"
  }'
  • event_types — lista de um ou mais eventos do catálogo acima. Precisa ter ao menos um item; eventos fora do catálogo são rejeitados (422 invalid_event_type).
  • target_url — precisa ser HTTPS e apontar para um host público (sem localhost, IP privado ou loopback); URL inválida devolve 422 invalid_target_url.

A resposta (201 Created) traz o secret da assinatura:

{
  "id": "1a2b...",
  "event_types": ["deal.created", "lead.received"],
  "target_url": "https://sua-aplicacao.com/webhooks/vendee",
  "secret": "whsec_...",
  "is_active": true,
  "created_at": "2026-08-11T12:00:00.000Z"
}

O secret só aparece nesta resposta, uma única vez — o CRM guarda apenas o valor necessário para assinar as entregas, e não tem como mostrá-lo de novo. Guarde-o com segurança; se perder, remova a assinatura e crie outra.

GET /api-v1-webhooks lista as assinaturas do workspace (sem o secret), com contagem de falhas e datas de sucesso/falha. DELETE /api-v1-webhooks/:id desativa uma assinatura (soft delete, 204 No Content).

O que chega no seu endpoint

Cada entrega é um POST com corpo JSON:

{
  "event": "deal.created",
  "workspace_id": "44c1...",
  "object_type": "deal",
  "object_id": "8f3c...",
  "data": { "...": "estado atual do objeto no momento da entrega" },
  "occurred_at": "2026-08-11T12:00:00.000Z",
  "delivery_id": "9e7a..."
}

E os headers:

HeaderConteúdo
X-Vendee-EventO mesmo valor de event no corpo (ex.: deal.created).
X-Vendee-Delivery-IdId único da entrega — use para deduplicar em caso de retry.
X-Vendee-TimestampUnix timestamp (segundos) da entrega, usado na assinatura.
X-Vendee-SignatureAssinatura HMAC no formato sha256=<hex>.

Verificar a assinatura

Recompute o HMAC-SHA256 do timestamp concatenado com o corpo bruto (não só o corpo — isso impede que o timestamp seja adulterado em trânsito sem invalidar a assinatura), usando o secret da sua assinatura, e compare com X-Vendee-Signature:

import { createHmac, timingSafeEqual } from "node:crypto";

function isValidSignature(rawBody, timestamp, signatureHeader, secret) {
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const received = signatureHeader.replace("sha256=", "");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

Use o corpo bruto da requisição (antes de fazer JSON.parse) — assinar o objeto já desserializado e reserializado pode produzir bytes diferentes e invalidar a comparação.

Retry e desativação automática

Uma entrega que falha (erro de rede, timeout ou resposta fora de 2xx) é reenviada com backoff crescente:

TentativaQuando
Imediata.
+30 segundos.
+5 minutos.
+30 minutos.
+6 horas.

Se a 5ª tentativa também falhar, a entrega é marcada como definitivamente falha e a assinatura é desativada automaticamente (is_active: false). Verifique failure_count/is_active via GET /api-v1-webhooks periodicamente, ou monitore 4xx/5xx no seu endpoint para saber quando precisa recriar a assinatura.

Seu endpoint precisa responder com um status 2xx para a entrega ser considerada bem-sucedida — o corpo da resposta não é lido. Redirects (3xx) não são seguidos e contam como falha.

Exemplo: reunião gravada vira nota no negócio (CRM-576)

Este é o desenho de referência para qualquer integração que grava/analisa reuniões de vendas (o caso de uso original é o Echo, do Cockpit) e devolve o resultado para dentro do negócio, no formato que o Otto consegue ler:

1. Você agenda a atividade "reunião" no CRM (com o deal_id do negócio)
        ↓  webhook activity.created
2. Sua integração recebe o evento — data.deal_id identifica o negócio,
   data.is_online_meeting diz se é uma call
        ↓  a reunião acontece, sua integração grava e analisa
3. Sua integração chama POST /api-v1-notes com o resumo, usando o gabarito abaixo

4. O Otto lê a nota automaticamente na próxima vez que consultar o negócio

⚠️ Pré-requisito: a atividade precisa ter deal_id (obrigatório em POST /api-v1-activities hoje) — reunião de lead que ainda não virou negócio não tem onde pendurar a nota. Enquanto esse é o caso, cubra apenas reunião de negócio já criado.

O gabarito da nota

O Otto lê comments.body como texto corrido, não como campo estruturado — a forma como você escreve decide o que ele consegue usar. Um resumo em prosa solta vira contexto vago; rótulos explícitos viram informação acionável. Use este formato (campos que sua integração não tiver, omita a linha — não escreva rótulo vazio):

Reunião: <tipo> · <data> · <duração>
Participantes: <lista>
Decisor presente: sim/não
Budget discutido: sim/não
Sentimento: <valor>   Probabilidade de avanço: <valor>
Resumo: <texto>
Próximo passo sugerido: <texto>

Critério para escolher o que entra: o que muda a decisão comercial do vendedor. Nota longa demais afoga o Otto em contexto; curta demais não serve para nada. Comece enxuto e ajuste com uso real — este gabarito não é definitivo, é o ponto de partida (decisão de 25/08/2026: "começar pelo texto e descobrir usando").

curl -X POST "{BASE_URL}/api-v1-notes" \
  -H "X-API-Key: vnd_sua_chave_aqui" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reuniao-<id-da-call>" \
  -d '{
    "deal_id": "8f3c...",
    "body": "Reunião: descoberta · 27/08/2026 · 42min\nParticipantes: João (comprador), Maria (SDR)\nDecisor presente: sim\nBudget discutido: não\nSentimento: positivo   Probabilidade de avanço: alta\nResumo: cliente confirmou dor com o processo atual e pediu proposta.\nPróximo passo sugerido: enviar proposta em até 2 dias."
  }'

Reprocessar a mesma call não duplica a nota

Use um Idempotency-Key estável, derivado do id da própria reunião/call (não um valor aleatório por tentativa) — reenviar a mesma chamada (retry de rede, reprocessamento) não cria uma segunda nota. Ver Notas → Idempotência.

O que não é resolvido aqui

O Otto vai citar a call na resposta, não raciocinar sobre ela (ex.: cruzar "três calls seguidas sem o decisor" exigiria o sinal chegar estruturado, não em prosa) — isso é trabalho de uma onda futura.

Nesta página