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
| Evento | Quando dispara |
|---|---|
deal.stage_changed | Um negócio muda de etapa no pipeline. |
deal.won | Um negócio é marcado como ganho. |
deal.lost | Um negócio é marcado como perdido. |
deal.stale | Um negócio fica parado N dias sem movimento (verificação horária). |
activity.created | Uma atividade é criada. |
activity.overdue | Uma atividade passa da data prevista sem ser concluída. |
proposal.viewed | Uma proposta é aberta pela primeira vez pelo destinatário. |
deal.created | Um negócio é criado (pelo dashboard ou via POST /api-v1-deals). |
contact.created | Um contato é criado (pelo dashboard ou via POST /api-v1-contacts). |
lead.received | Um 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.accepted | Uma proposta é aceita pelo cliente através do link público. |
proposal.rejected | Uma 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 (semlocalhost, IP privado ou loopback); URL inválida devolve422 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:
| Header | Conteúdo |
|---|---|
X-Vendee-Event | O mesmo valor de event no corpo (ex.: deal.created). |
X-Vendee-Delivery-Id | Id único da entrega — use para deduplicar em caso de retry. |
X-Vendee-Timestamp | Unix timestamp (segundos) da entrega, usado na assinatura. |
X-Vendee-Signature | Assinatura 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:
| Tentativa | Quando |
|---|---|
| 1ª | Imediata. |
| 2ª | +30 segundos. |
| 3ª | +5 minutos. |
| 4ª | +30 minutos. |
| 5ª | +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.