# Autenticação (/authentication)
Todas as operações públicas usam uma API key no esquema Bearer:
```http
Authorization: Bearer vsk_live_••••••••••••
```
As chaves pertencem ao workspace que as criou. O tenant é derivado da chave autenticada; a API ignora qualquer tentativa de informar `tenantId` no payload.
## Boas práticas [#boas-práticas]
* Use uma chave por integração ou ambiente para facilitar rotação e auditoria.
* Armazene a chave somente no backend, em um secret manager.
* Nunca envie o segredo por query string.
* Revogue imediatamente uma chave exposta e atualize a integração.
* Não registre o header `Authorization` em logs ou ferramentas de observabilidade.
## Respostas de autenticação [#respostas-de-autenticação]
Uma chave ausente, inválida ou revogada produz `401` com um código estável:
```json
{
"code": "API_KEY_INVALID",
"error": "Invalid API key"
}
```
Um `403` indica que a chave foi reconhecida, mas o workspace não pode executar a operação naquele momento — por acesso, assinatura ou entitlement.
## Scopes [#scopes]
Escolha as permissões mínimas ao criar a chave:
| Scope | Permissão |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `messages:send` | Enviar mensagens e consultar a entrega |
| `crm.contacts:read` / `crm.contacts:write` | Ler ou alterar contatos |
| `crm.companies:read` / `crm.companies:write` | Ler ou alterar empresas |
| `crm.deals:read` / `crm.deals:write` | Ler ou alterar negócios |
| `crm.configuration:read` | Ler pipelines, etapas, tags e campos personalizados |
| `webhooks:manage` | Gerenciar endpoints de webhook de saída: criar, atualizar, excluir, regenerar segredo, testar e reenviar entregas |
Uma chave sem o scope exigido recebe `403` com o código `API_KEY_SCOPE_REQUIRED`.
# Changelog (/changelog)
## 2026-10-02 — Webhooks de eventos [#2026-10-02--webhooks-de-eventos]
* Seção [Webhooks de eventos](/webhooks) no portal: visão geral, primeiros passos com n8n, assinatura Standard Webhooks (Node, Python e nó Code do n8n), formato do envelope, catálogo dos 23 tipos e a tabela de retentativas/desativação.
* Novo scope de API key `webhooks:manage`, para criar e gerenciar endpoints de webhook de saída sem passar pela UI.
* Gestão de endpoints e entregas disponível em `/api/v1/integrations/webhooks/*` — ainda sem referência OpenAPI gerada, documentada em tabela na própria seção.
## 2026-08-27 — Portal Vensy Developers [#2026-08-27--portal-vensy-developers]
* Portal público consolidado em `/docs` com busca e guias por tarefa.
* Referência gerada do mesmo contrato OpenAPI usado pelo servidor.
* Contrato OpenAPI público em `/api/v1/openapi.json`.
* Documentação explícita de `Idempotency-Key` e `Prefer: respond-async`.
* Correção do exemplo de template: variáveis agora aparecem no nível raiz do payload.
* Swagger legado em `/documentation` redireciona para o novo portal.
## Versionamento [#versionamento]
Endpoints estáveis permanecem sob `/api/v1`. Mudanças incompatíveis exigem uma nova versão de rota e serão anunciadas nesta página antes da remoção de um contrato suportado.
# MCP (/connect-ai)
A Vensy expõe um servidor MCP para Claude, Codex e ChatGPT. Você configura a URL, faz login na sua conta e autoriza o workspace. O aplicativo passa a operar com o **mesmo papel** de quem autorizou.
## URL do conector [#-url-do-conector]
```text
https://mcp.vensy.com.br/mcp
```
É a mesma URL para todos os workspaces. O workspace é escolhido na tela de autorização, depois do login.
No app, a URL também está em **Configurações → MCP**, com botão de copiar.
## Claude [#-claude]
1. Abra as **Configurações** do Claude.
2. Vá em **Conectores**.
3. Escolha **Adicionar conector personalizado**.
4. Cole a URL `https://mcp.vensy.com.br/mcp`.
5. Clique em **Conectar**.
6. Faça login na Vensy.
7. Escolha o workspace.
8. Autorize o acesso.
## Codex [#-codex]
1. Abra o terminal com o Codex instalado.
2. Adicione o servidor MCP da Vensy:
```bash
codex mcp add vensy --url https://mcp.vensy.com.br/mcp
```
3. Inicie a autorização:
```bash
codex mcp login vensy
```
4. Faça login na Vensy, escolha o workspace e autorize o acesso.
## ChatGPT [#-chatgpt]
1. Abra as **Configurações** do ChatGPT.
2. Ative o **modo desenvolvedor**.
3. Adicione um conector MCP.
4. Cole a URL `https://mcp.vensy.com.br/mcp`.
5. Autorize o acesso na Vensy e escolha o workspace.
## Vários workspaces [#vários-workspaces]
Na autorização você marca um ou mais workspaces. A IA só enxerga os marcados e, em cada um, age com o papel que você tem nele. Com mais de um marcado, ela precisa dizer em qual workspace vai agir a cada pedido; quando o pedido não deixa claro, ela pergunta. Ela nunca escolhe sozinha.
## Permissões [#permissões]
| Permissão | O que libera |
| ----------- | ----------------------------------------------------------------------------------------- |
| `mcp:read` | Ler contatos, empresas, negócios, conversas, campanhas, funis e relatórios |
| `mcp:write` | Criar e alterar registros, registrar notas, enviar mensagens, publicar funis e automações |
Uma conexão só com `mcp:read` não altera nada: toda tentativa de escrita é recusada pela Vensy, que devolve ao aplicativo o pedido de reconexão com a permissão de escrita. A IA age com o papel de quem autorizou. Um agente conectado **não** ganha poderes de administrador.
## O que a IA vê e o que pode fazer [#o-que-a-ia-vê-e-o-que-pode-fazer]
* **Dados compartilhados**: os dados dos workspaces autorizados que o seu papel já permite ver — contatos (nome, telefone, e-mail), negócios, conversas e mensagens, notas internas, campanhas, funis e métricas. Senhas, tokens e segredos de integração não são devolvidos.
* **Efeitos**: leituras não mudam nada. Escritas mudam o workspace de verdade. Envio de mensagem, campanha e publicação de funil ou automação falam com clientes reais e não têm volta.
* **Confirmação**: ações destrutivas, em massa ou de cobrança mostram antes o efeito e só executam numa segunda chamada, com um token que vale 5 minutos para aquele mesmo pedido.
* **Segredos**: a IA não pede senha, token nem chave, e não recebe segredos de volta. Credenciais de automação se cadastram no app, em **Automações → fluxo → Segurança e horários**; o segredo do webhook de uma automação se gera em **Entrada e testes**; o WhatsApp oficial se conecta em **Canais**.
A IA vê dados de clientes reais. Revise o que ela propõe antes de confirmar envios e ações em massa.
## Limites [#limites]
* Cerca de 100 chamadas de ferramenta por minuto por conexão, além do limite da própria API.
* Listas voltam em páginas de 25 itens; a IA segue o cursor quando precisa de mais. Resposta grande demais volta cortada e marcada como incompleta, nunca como se estivesse inteira.
* Depois de uma falha de rede numa escrita, a IA deve conferir o resultado antes de repetir: criação, nota e envio podem duplicar.
## Revogar o acesso [#revogar-o-acesso]
Em **Configurações → MCP**, a lista **Conexões ativas** mostra cada aplicativo autorizado (nome, data e permissões), com o botão **Revogar**.
Depois de revogar, o acesso cai em até 5 minutos: o token tem validade curta e a verificação é local.
Quem ainda não conectou nada vê a lista vazia nessa mesma tela.
# API do CRM (/crm)
Use a API do CRM para manter sistemas externos sincronizados com o workspace Vensy. O workspace é sempre derivado da API key: não envie `tenantId` no path, query ou payload.
Os endpoints exigem que a API do CRM esteja habilitada para o workspace, além do scope correspondente na chave.
## Base URL [#base-url]
```text
https://vensy.com.br/api/v1/integrations/crm
```
## Recursos [#recursos]
| Recurso | Operações |
| ------------- | --------------------------------------------------------- |
| Contatos | listar, obter, criar, atualizar, arquivar e restaurar |
| Tags e campos | substituir tags ou campos personalizados de um contato |
| Empresas | listar, obter, criar, atualizar, arquivar e restaurar |
| Negócios | listar, obter, criar, atualizar, ganhar, perder e reabrir |
| Configuração | consultar pipelines, etapas, tags e definições de campos |
### Endpoints [#endpoints]
| Método | Caminho |
| ------------------------ | ----------------------------------------------------------- |
| `GET`, `POST` | `/contacts` |
| `GET`, `PATCH`, `DELETE` | `/contacts/{id}` |
| `POST` | `/contacts/{id}/restore` |
| `PUT` | `/contacts/{id}/tags` |
| `PUT` | `/contacts/{id}/custom-fields` |
| `GET`, `POST` | `/companies` |
| `GET`, `PATCH`, `DELETE` | `/companies/{id}` |
| `POST` | `/companies/{id}/restore` |
| `PUT` | `/companies/{id}/custom-fields` |
| `GET`, `POST` | `/deals` |
| `GET`, `PATCH` | `/deals/{id}` |
| `POST` | `/deals/{id}/won`, `/deals/{id}/lost`, `/deals/{id}/reopen` |
| `GET` | `/configuration` |
## Criar um contato [#criar-um-contato]
Toda escrita exige `Idempotency-Key` com 8 a 128 caracteres. Repetir a mesma chave e o mesmo payload devolve o resultado original; reutilizá-la com outro payload produz conflito.
```bash
curl -X POST https://vensy.com.br/api/v1/integrations/crm/contacts \
-H "Authorization: Bearer vsk_live_SUA_CHAVE" \
-H "Idempotency-Key: erp-contact-0001" \
-H "Content-Type: application/json" \
-d '{
"phoneE164": "+5511999999999",
"customName": "Maria Souza",
"email": "maria@example.com",
"ownerEmail": "vendedor@empresa.com",
"tagIds": ["tag_id"],
"customFields": { "customer_code": "ERP-42" }
}'
```
`ownerEmail` deve pertencer a um membro ativo do workspace. Quando informado, o servidor resolve a membership sem expor IDs internos.
## Listas e paginação [#listas-e-paginação]
Coleções retornam `{ data, pagination: { nextCursor } }`. Reenvie o cursor opaco sem interpretá-lo. Os filtros comuns são `cursor`, `limit` (1–100), `q`, `archived` e `updatedAfter`; contatos também aceitam `phoneE164`, `email` e `tagId`.
```bash
curl --get https://vensy.com.br/api/v1/integrations/crm/contacts \
-H "Authorization: Bearer vsk_live_SUA_CHAVE" \
--data-urlencode "email=cliente@empresa.com"
```
## Comportamento importante [#comportamento-importante]
* `DELETE` faz arquivamento lógico; use `/restore` para restaurar.
* `GET /deals` exige `pipelineId`.
* A tela do CRM organiza negócios. Criar um contato também materializa o negócio inicial para que ele apareça no board.
* Alterar `pipelineStageId` no contato não move um card existente; mova-o com `PATCH /deals/{id}`.
* Atualizações e transições de negócio resolvem a versão atual internamente; a integração não envia `expectedVersion`.
## Erros [#erros]
Os casos mais comuns são `API_KEY_INVALID` (`401`), `API_KEY_SCOPE_REQUIRED` (`403`), `ENTITLEMENT_CRM_API_DISABLED` (`403`), validação (`400`), recurso não encontrado (`404`) e conflito de idempotência ou versão (`409`). Consulte o [catálogo de erros](/errors-and-limits).
Para importar o contrato completo em ferramentas de desenvolvimento, use o [OpenAPI JSON](https://vensy.com.br/api/v1/openapi.json).
# Status de entrega (/delivery-status)
Faça `GET /api/v1/messages/{id}` usando o `providerMessageId` retornado no envio.
```json
{
"providerMessageId": "3EB0A1B2C3",
"conversationId": null,
"contentType": "TEXT",
"direction": "OUTBOUND",
"ack": 2,
"ackName": "DELIVERED",
"status": "delivered",
"createdAt": "2026-08-27T18:30:00.000Z"
}
```
| Status | Significado |
| ----------- | ----------------------------------------------------- |
| `sent` | Aceita pelo canal ou aguardando confirmação posterior |
| `delivered` | Entregue ao dispositivo do destinatário |
| `read` | Marcada como lida pelo destinatário |
| `failed` | Falha conhecida depois da aceitação |
Os acknowledgements dependem do provedor e nem toda mensagem percorre todos os estados. Trate `sent` como aceitação do disparo, não como confirmação de leitura.
## Polling [#polling]
Use backoff progressivo — por exemplo, após 2 s, 5 s, 15 s, 30 s e 60 s — e pare quando chegar a `read` ou `failed`, ou quando seu prazo de negócio expirar.
# Erros e limites (/errors-and-limits)
Erros usam um envelope consistente:
```json
{
"code": "VALIDATION_ERROR",
"error": "Invalid send API payload",
"details": {
"issues": []
}
}
```
| HTTP | Categoria | Ação recomendada |
| ----- | ------------------------- | -------------------------------------------- |
| `400` | Payload inválido | Corrija os campos; não repita sem alteração |
| `401` | API key inválida | Corrija ou rotacione a credencial |
| `403` | Acesso ou entitlement | Verifique workspace, assinatura e limites |
| `404` | Mensagem não encontrada | Confirme o ID e o workspace da chave |
| `409` | Conflito de estado | Reavalie canal ou estado do contato |
| `422` | Destino/conteúdo recusado | Corrija telefone, mídia ou template |
| `429` | Rate limit | Respeite `Retry-After` e aplique jitter |
| `500` | Falha interna | Repita com backoff e a mesma idempotency key |
| `502` | Falha do provedor | Repita com backoff e a mesma idempotency key |
| `503` | Dependência indisponível | Aguarde e repita com backoff |
## Rate limit [#rate-limit]
O limite de envio é aplicado por API key; a consulta de status tem orçamento separado. Os valores podem variar por ambiente e configuração operacional.
Ao receber `429`, não faça retry imediato em paralelo. Aguarde `Retry-After`, aplique jitter e reduza a concorrência. O limite de volume do plano continua sendo controlado pelos entitlements do workspace.
# Vensy API (/)
A API pública da Vensy permite disparar mensagens WhatsApp, consultar entregas e sincronizar contatos, empresas e negócios do CRM. Ela foi desenhada para integrações backend, com autenticação por API key, scopes e idempotência.
Um disparo pela API registra uma tentativa de outreach, mas não abre um atendimento no inbox. A conversa é criada quando o contato responde.
Crie uma chave, envie sua primeira mensagem e consulte o status.
Texto, mídia, localização e templates do WhatsApp Oficial.
Contatos, empresas, negócios e configurações do workspace.
Seja avisado em tempo real quando algo acontecer no workspace.
Schemas, respostas e exemplos gerados do contrato OpenAPI.
Idempotência, retries e processamento assíncrono.
## Endpoints disponíveis [#endpoints-disponíveis]
| Método | Endpoint | Uso |
| ------ | ---------------------------- | -------------------------------------------- |
| `POST` | `/api/v1/messages` | Enviar texto, mídia, localização ou template |
| `POST` | `/api/v1/messages/template` | Enviar template por WhatsApp Cloud |
| `GET` | `/api/v1/messages/{id}` | Consultar status de entrega |
| Vários | `/api/v1/integrations/crm/*` | Gerenciar contatos, empresas e negócios |
O contrato OpenAPI completo e filtrado somente para operações de API key está disponível em [`/api/v1/openapi.json`](https://vensy.com.br/api/v1/openapi.json).
# Início rápido (/quickstart)
## 1. Crie uma API key [#1-crie-uma-api-key]
No app da Vensy, abra **Configurações → Chaves de API** e selecione **Nova chave**. O segredo começa com `vsk_live_` e aparece uma única vez.
Salve o segredo em um cofre de credenciais ou variável de ambiente. Não coloque a chave em frontend, aplicativo móvel, repositório ou log.
## 2. Envie uma mensagem [#2-envie-uma-mensagem]
```bash
curl --request POST 'https://vensy.com.br/api/v1/messages' \
--header "Authorization: Bearer $VENSY_API_KEY" \
--header 'Content-Type: application/json' \
--header "Idempotency-Key: $(uuidgen)" \
--data '{
"to": "+5511999999999",
"type": "text",
"text": "Olá! Sua solicitação foi recebida."
}'
```
Uma resposta síncrona bem-sucedida usa `200`. Quando você envia `Prefer: respond-async`, a API pode responder `202` com `status: "queued"`.
```json
{
"messageId": "3EB0A1B2C3",
"providerMessageId": "3EB0A1B2C3",
"outreachId": "cm_outreach_123",
"firingChannelId": "cm_channel_123",
"conversationId": null,
"status": "sent",
"createdAt": "2026-08-27T18:30:00.000Z",
"idempotencyReplayed": false,
"apiKeyId": "cm_key_123"
}
```
## 3. Consulte o status [#3-consulte-o-status]
Use o `providerMessageId` retornado no envio:
```bash
curl 'https://vensy.com.br/api/v1/messages/3EB0A1B2C3' \
--header "Authorization: Bearer $VENSY_API_KEY"
```
Os estados públicos são `sent`, `delivered`, `read` e `failed`. Veja [Status de entrega](/delivery-status) para definir sua estratégia de polling.
# Consultar status de entrega (/api-reference/get-message-status)
# Enviar mensagem (/api-reference/send-message)
# Enviar template oficial (/api-reference/send-template)
# Criar fluxos com IA e importar JSON (/automations/ai-contract)
Uma IA pode ajudar a montar o arquivo Vensy; ela não precisa receber sua sessão, token do webhook, credenciais ou dados reais de clientes. Importar sempre cria **rascunho**. Revise referências, simule e publique no app.
## Fontes legíveis por máquina [#fontes-legíveis-por-máquina]
* [JSON Schema versão 1](https://vensy.com.br/docs/automation-schema.json), gerado do mesmo validador usado por backend e editor.
* [Receitas completas em JSON](https://vensy.com.br/docs/automation-recipes.json), com definições e amostras fictícias.
* [Índice para LLMs](https://vensy.com.br/docs/llms.txt) e [documentação completa](https://vensy.com.br/docs/llms-full.txt).
O JSON Schema descreve a forma, mas não prova que o grafo é executável nem que canal, template, vendedor e CRM pertencem ao workspace. Essas verificações ocorrem no servidor ao publicar e novamente no consumo.
## Instrução sugerida para sua IA [#instrução-sugerida-para-sua-ia]
```text
Crie uma automação comercial Vensy schemaVersion 1.
Leia o JSON Schema, as receitas e a documentação de blocos.
Responda apenas com JSON válido, sem JavaScript e sem comentários.
Use um único trigger, um entryNodeId que aponte para ele e término end.
Use IDs de blocos estáveis, únicos, iniciados por letra e com até 64 caracteres.
Todas as saídas next/trueNext/falseNext precisam existir. Não crie ciclos.
Use somente blocos presentes no schema; não invente conectores ou tipos.
Campos dinâmicos usam {{trigger.body.campo}} ou outras raízes documentadas.
Use {{steps.id_do_envio.status}} igual a SENT quando uma ação depender da
confirmação de um WhatsApp anterior. Use runtime.contactReplied para ramificar
pelo estado vivo; stopOnReply bloqueia novos envios, não ações de CRM de um
envio que o provedor já aceitou.
Para mensagens diferentes dentro e fora do expediente, ramifique por
runtime.withinBusinessHours igual a true e use respectSendWindow false,
senão a mensagem de fora do horário espera o próximo horário comercial.
Nunca inclua tenantId, credenciais, segredos, cookies nem dados reais de clientes.
Não invente IDs de pipeline, etapa, tag, membro, canal, template ou credencial.
Deixe referências indisponíveis vazias para seleção humana posterior no editor.
Nunca autorize envio apenas porque o formulário foi preenchido:
mapeie a escolha real do lead para consent com uma expressão de caminho exata.
Nunca use consent "true" fixo. Use consent "" somente para enviar dentro da janela de 24h.
Em templates, bodyVariables segue a ordem das posições {{1}}/{{2}} ou dos nomes
encontrados no corpo {{nome}}/{{produto}}. Cada item pode ser campo ou texto fixo.
Use {{contact.name}}, {{contact.firstName}} ou {{contact.lastName}} quando a
variável pedir nome completo, primeiro nome ou último nome do contato.
Mantenha stopOnReply e stopOnDealClosed ligados, e um horário de envio adequado.
Inclua CREATE/REUSE_OPEN explicitamente no bloco de negócio.
Para distribuição, use availabilityPolicy ONLINE_CAPACITY ou ALL_ACTIVE.
Use distributionMode EQUAL, ou PERCENTAGE com uma chave por membershipId e soma 100.
Não misture customFields de contato e negócio; deixe mapas sem referências vazios.
Não tente publicar/ativar: o resultado será importado como rascunho.
Minha necessidade: [descreva aqui o fluxo e um exemplo fictício dos campos].
```
## Formato do arquivo [#formato-do-arquivo]
O editor aceita uma definição isolada ou este envelope. O exemplo mínimo é importável e não realiza nenhuma ação externa:
```json
{
"vensyAutomationExportVersion": 1,
"name": "Meu fluxo comercial",
"definition": {
"schemaVersion": 1,
"entryNodeId": "start",
"trigger": { "type": "WEBHOOK" },
"settings": {
"stopOnReply": true,
"stopOnDealClosed": true,
"maxAgeDays": 30,
"timezone": "America/Sao_Paulo",
"sendWindowStart": "08:00",
"sendWindowEnd": "18:00",
"sendWeekdays": [1, 2, 3, 4, 5],
"respectSendWindow": true
},
"nodes": [
{ "id": "start", "type": "trigger", "label": "Evento recebido", "position": { "x": 320, "y": 60 }, "next": "end" },
{ "id": "end", "type": "end", "label": "Concluir", "position": { "x": 320, "y": 250 } }
]
}
}
```
Use a receita `lead-capture` no JSON de receitas para o fluxo completo **contato → negócio → tag → responsável → WhatsApp**. Ela já define o encadeamento; referências ficam vazias até a escolha no workspace.
## Importação e exportação seguras [#importação-e-exportação-seguras]
O limite do arquivo é 512 KiB e 80 blocos. Exportações não incluem segredos do webhook, valores de credenciais, amostras capturadas ou histórico. Vínculos de pipeline, etapa, tags, responsáveis, canais, templates e credenciais são removidos para evitar reaproveitamento acidental em outro workspace.
Referências de campos personalizados de contato e negócio também são removidas da exportação portátil; selecione novamente as definições do workspace de destino. O fluxo pode conter textos fixos que você próprio digitou: revise-os antes de compartilhar. Não cole senhas em texto de mensagem, URL ou JSON HTTP.
Versões publicadas são imutáveis. Importar/exportar não concede permissões, não contorna o plano e não altera os Funis de atendimento existentes.
# Blocos e mapeamento de dados (/automations/blocks)
## Campos de origem [#campos-de-origem]
Use o seletor/sugestões do editor ou escreva uma referência entre chaves duplas. O formato não executa código.
| Referência | Conteúdo |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{{trigger.body.nome}}` | Campo do evento original |
| `{{trigger.body["Nome completo"]}}` | Campo cujo nome tem espaços |
| `{{trigger.body.itens[0].sku}}` | Primeiro item de uma lista |
| `{{fields.interesse}}` | Saída do bloco Organizar dados |
| `{{contact.id}}`, `{{contact.name}}`, `{{contact.phone}}` | Contato criado/encontrado; `name` é o nome completo |
| `{{contact.firstName}}`, `{{contact.lastName}}` | Primeiro e último nome derivados do nome atual do contato |
| `{{contact.customFields.opt_in}}` | Campo personalizado existente, quando carregado do CRM |
| `{{deal.id}}`, `{{deal.status}}`, `{{deal.valueMinor}}` | Negócio atual; valor em centavos |
| `{{deal.stageId}}`, `{{deal.stageName}}` | Etapa atual do negócio; o nome acompanha a etapa depois de criar, reutilizar ou mover |
| `{{owner.id}}`, `{{owner.name}}` | Responsável selecionado |
| `{{steps.consultar_api.body.codigo}}` | Resultado de uma etapa cujo ID é `consultar_api` |
| `{{steps.boas_vindas.status}}` | Resultado do envio: `SENT` quando o provedor aceitou a mensagem |
| `{{runtime.contactReplied}}` | Se o contato respondeu desde o início desta execução |
| `{{runtime.contactOptedOut}}`, `{{runtime.dealClosed}}` | Estado vivo de opt-out e encerramento do negócio |
| `{{runtime.withinBusinessHours}}` | Se agora está dentro do horário comercial do fluxo (`timezone`, `sendWindowStart`/`sendWindowEnd`, `sendWeekdays`), mesmo com `respectSendWindow` desligado |
Texto pode combinar frases e referências: `Olá, {{contact.name}}!`. Dentro de JSON, uma referência sozinha preserva números, booleanos, objetos ou listas; dentro de uma frase produz texto. Campo ausente vira texto vazio ou `null` em JSON. Use campos obrigatórios/condições para não depender silenciosamente de dados ausentes.
Não use funções, JavaScript, filtros `|`, operadores, `eval`, SQL ou sintaxe `$json` do n8n. As raízes disponíveis são somente `trigger`, `fields`, `contact`, `deal`, `owner`, `steps` e `runtime`.
## CRM [#crm]
| Bloco | Comportamento |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Organizar dados | Cria um mapa em `fields`; os campos marcados obrigatórios precisam existir |
| Criar ou atualizar contato | Localiza pelo telefone normalizado, incluindo variantes brasileiras; aceita nome, e-mail, empresa, cargo, cidade, UF, país, nascimento, origem, observações e campos personalizados de contato |
| Criar ou reutilizar negócio | `REUSE_OPEN` reutiliza a oportunidade aberta mais recente do contato no pipeline; `CREATE` cria outra oportunidade; ambos podem preencher campos personalizados de negócio |
| Atualizar negócio | Muda etapa dentro do mesmo pipeline, status `OPEN`, `WON`, `LOST` e/ou campos personalizados; exige negócio no contexto |
| Alterar tags | Adiciona/remove apenas tags selecionadas do workspace |
| Distribuir lead | Usa responsável fixo ou rodízio atômico, igual ou percentual, entre os membros escolhidos |
Contato arquivado não é reativado automaticamente. Se houver cadastros ambíguos para o telefone, a execução para para revisão. A automação não cria um negócio implicitamente ao criar contato: inclua o bloco de negócio quando precisar de uma oportunidade.
`opportunityKey` permite reutilizar o mesmo pedido/orçamento entre eventos distintos, dentro do mesmo contato e pipeline. Prefira um identificador estável do sistema de origem. O valor `valueMinor` é inteiro não negativo em centavos: `15000` representa R$ 150,00.
Campos personalizados aparecem separados por escopo: um campo de contato não pode ser gravado no negócio e vice-versa. Eles devem existir previamente e receber o tipo correto: número JSON para NUMBER, booleano para BOOLEAN, lista para MULTI\_SELECT. Expressões que apontam para dados ausentes preservam o campo existente; `null` explícito pode limpá-lo. Não coloque autorização, API keys ou senhas em campos de lead.
### Vendedor e canal são decisões separadas [#vendedor-e-canal-são-decisões-separadas]
O responsável fixo precisa ser operacional e ativo, mas pode estar offline: é uma atribuição comercial explícita. No modo balanceado, escolha uma política:
* **Somente online e com capacidade**: considera presença recente e vagas no atendimento. Sem elegíveis, a execução aguarda.
* **Todas as pessoas ativas selecionadas**: mantém um rodízio comercial contínuo e não muda a proporção porque alguém ficou temporariamente offline.
A proporção pode ser **igual** ou por **percentuais**. Nos percentuais, cada pessoa recebe um inteiro positivo e o total precisa ser 100%. A Vensy intercala as escolhas ao longo do ciclo; em 50/50, por exemplo, os responsáveis alternam enquanto ambos continuarem elegíveis. Se a política online deixar apenas parte da equipe elegível, os pesos relativos são aplicados somente a essa parte naquele momento.
**Manter o dono atual do negócio** mantém o responsável ativo/operacional de um negócio reutilizado, mesmo que esteja offline. Desmarque para redistribuir. A seleção fica persistida para que retries não alternem vendedores. Nenhum atendimento ocupa capacidade antes de o contato responder.
**Canal que fará o envio** não atribui o lead: ele escolhe apenas a conexão remetente. O WhatsApp pode usar um canal fixo ou o canal vinculado ao dono do negócio. Neste último caso, coloque a distribuição antes do envio ou reutilize um negócio que já tenha dono. Se o responsável tiver vários canais elegíveis, selecione um canal fixo; a automação não escolhe um remetente ambíguo.
## WhatsApp [#whatsapp]
Texto, imagem, vídeo, áudio e documento usam os gateways já conectados à Vensy. Áudio não aceita legenda; use um bloco de texto separado. Mídias são importadas para armazenamento protegido antes do envio.
No oficial, fora da janela de atendimento, use um template aprovado do próprio canal. O editor reconhece tanto posições (`{{1}}`, `{{2}}`) quanto parâmetros nomeados da Meta (`{{nome}}`, `{{produto}}`). Em cada variável, **Preencher com** permite escolher nome completo, primeiro nome, último nome, outro campo padrão ou personalizado do contato, um campo do negócio, um dado recebido pelo formulário/gatilho ou um valor fixo. Primeiro e último nome são calculados do nome atual do contato; quando existe apenas uma palavra, ambos usam essa palavra. A prévia mostra o vínculo; o valor real é lido somente na execução. Todas as variáveis precisam estar preenchidas para publicar.
Cabeçalho de texto/mídia e parâmetros dos botões URL/copiar código continuam seguindo exatamente o template aprovado. A aprovação é conferida novamente ao enviar; parâmetros nomeados são enviados à Meta com o identificador correspondente. No não oficial, um template é renderizado como texto do corpo; cabeçalho dinâmico e botões não são transportados como template oficial.
Em **Permissão para iniciar contato no WhatsApp**, escolha uma destas modalidades:
* **Tenho o opt-in registrado em um campo**: selecione o campo real do formulário ou um campo personalizado `sim/não` do CRM. Ele precisa resolver para `true` booleano ou texto `"true"` após uma escolha afirmativa da pessoa.
* **Enviar somente dentro da janela de 24 horas**: não exige opt-in proativo, mas só envia se a própria pessoa tiver falado naquele mesmo canal nas últimas 24 horas. Um formulário preenchido não abre essa janela.
Um valor fixo `true` não comprova consentimento e impede a publicação. Template aprovado também não substitui opt-in. Opt-out tem prioridade e não pode ser desligado pelo fluxo.
Enviar proativamente não cria atendimento. O bloco só segue pelo caminho de sucesso depois que o provedor aceita a mensagem (`SENT`); fila, falha e resultado incerto não são tratados como sucesso. Para mover o negócio apenas depois do envio, conecte **Enviar WhatsApp → Condição** com `steps..status` igual a `SENT` → **Atualizar negócio**.
A resposta usa o handoff comercial existente e, por padrão, encerra o follow-up. Se ela chegar logo após uma mensagem confirmada, as condições e ações de CRM ligadas àquele sucesso ainda concluem; o próximo bloco de WhatsApp é interrompido. Uma mensagem ainda enfileirada é reavaliada antes de chegar ao provedor. Desligar “parar na resposta” não remove as proteções do Inbox, do canal ou do contato.
## Lógica e integrações [#lógica-e-integrações]
**Condição** aceita igualdade/diferença, contém, preenchido/vazio e comparação numérica; combina todas (`ALL`) ou alguma (`ANY`). Pode consultar o evento, contato, negócio, dono, `runtime` e saídas persistidas em `steps`. Você pode adicionar vários blocos de condição. Ambas as saídas precisam chegar a um encerramento. Não há loops nem execução paralela de ramos: cada condição escolhe um caminho.
**Aguardar** mantém o estado no banco, sem ocupar um processo por minutos/horas, e pode aparecer várias vezes no mesmo fluxo. Depois da retomada, condições e atualizações de CRM podem concluir normalmente; antes de um novo envio, resposta, opt-out, estado do negócio, plano e horário são conferidos novamente.
**Notificar equipe** gera um aviso operacional para os administradores do workspace. Não envia email, WhatsApp nem mensagens individuais a todos os vendedores.
**Requisição HTTP** permite GET/POST/PUT/PATCH/DELETE para domínios públicos HTTPS, na porta padrão. Não aceita rede privada, IP literal, redirecionamento ou código. Cadastre segredos em **Segurança e horários → Credenciais**: o token fica criptografado e só pode ser enviado à origem cadastrada. Headers de transporte/roteamento são controlados pela Vensy.
Por padrão, uma chamada mutante cujo resultado é desconhecido vai para revisão, sem repetir. Só marque **API aceita repetição segura** se a API externa documentar idempotência e realmente consumir o header configurado. Meramente enviar `Idempotency-Key` não torna um endpoint idempotente. HTTP usa uma chave estável por execução/bloco, inclusive em retomadas administrativas.
O corpo de saída HTTP tem até 32 KiB e a resposta persistida até 16 KiB; respostas maiores são omitidas (`bodyOmitted`). Campos sensíveis conhecidos e o token cadastrado são redigidos, mas APIs externas podem devolver outros dados pessoais: escolha o destino e o conteúdo com cuidado.
# Automações comerciais (/automations)
Automações comerciais iniciam por eventos, mesmo sem conversa no Inbox. Os **Funis de atendimento** existentes continuam cuidando da conversa; os dois recursos são complementares.
## Seu primeiro fluxo [#seu-primeiro-fluxo]
No app, abra **Automações → Comerciais** como proprietário ou administrador e escolha **Capturar lead do site**.
```text
Formulário / webhook
→ Criar ou atualizar contato
→ Criar ou reutilizar negócio
→ Adicionar tag
→ Definir responsável ou distribuir equipe
→ Enviar WhatsApp
→ Concluir
```
1. Selecione o pipeline, a etapa, as tags, o vendedor/equipe e o canal do seu workspace.
2. Em **Entrada e testes**, escolha a proteção do webhook e copie sua URL. Use [o guia de integração](/automations/webhooks) para conectar o formulário.
3. Receba uma amostra ou cole dados fictícios. Mapeie telefone, nome, origem e opt-in. No template, escolha para cada variável o nome completo, primeiro ou último nome, outro campo do contato/negócio, um dado do formulário ou um valor fixo; **Simular sem enviar** mostra o caminho previsto sem cadastrar ou enviar nada.
4. **Salvar rascunho** não altera o fluxo ativo. Revise e confirme **Publicar e ativar** para receber eventos reais.
5. Acompanhe **Execuções**. **Versões** permite carregar uma publicação anterior como novo rascunho, sem alterar execuções em andamento.
Você pode conectar os blocos arrastando ou usando os seletores **Próximo bloco**, **Sim** e **Não**. O editor recusa ciclos. As receitas também incluem distribuição, follow-up sem resposta, mudança de etapa e recuperação de interesse por evento externo.
## Reaproveitar um fluxo [#reaproveitar-um-fluxo]
Na listagem de **Fluxos comerciais**, use **Duplicar** para criar outro rascunho identificado por **Cópia de**, mantendo descrição e blocos. A cópia abre automaticamente para revisão e recebe URL e segredo de webhook próprios. Versões publicadas, amostras de teste e execuções do fluxo original não são copiadas.
Revise principalmente canais, responsáveis, credenciais e textos antes de publicar a cópia. Alterar ou publicar o novo fluxo não muda o original.
Um formulário não abre a janela de atendimento do WhatsApp oficial. Para iniciar contato fora dessa janela, selecione um template aprovado e escolha o campo que registra o consentimento real do lead. Um valor fixo `true` é recusado. Se não houver opt-in, escolha enviar somente após a pessoa falar naquele canal, dentro da janela de 24 horas. O canal não oficial também exige consentimento para envios proativos.
## Quais eventos podem iniciar? [#quais-eventos-podem-iniciar]
| Gatilho | Uso |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| Webhook | Formulários, pedidos, orçamentos, carrinhos e sistemas externos capazes de fazer POST |
| Manual | Execução real confirmada pelo administrador, separada da simulação |
| Contato criado/alterado | Reagir a mudanças de cadastro no CRM |
| Tag adicionada/removida | Iniciar uma jornada por segmentação |
| Negócio criado, etapa alterada, ganho ou perdido | Ações comerciais relacionadas à oportunidade |
| Agendamento | Iniciar periodicamente, em intervalo de pelo menos 5 minutos |
| Inatividade | Buscar negócios abertos sem atualização pelo período configurado, com filtro de pipeline/etapa |
Um agendamento simples recebe uma data; não seleciona todos os contatos automaticamente. Para negócios parados, use **Inatividade**. “Inatividade” considera a última alteração do negócio, não a última visita ao site. Eventos produzidos pelos blocos nativos da própria automação não iniciam outras automações recursivamente.
## Quem pode usar? [#quem-pode-usar]
Somente OWNER/ADMIN configuram, publicam, testam e consultam o histórico. O plano controla habilitação, quantidade de fluxos ativos e execuções mensais. O administrador da plataforma pode conceder uma liberação específica; o administrador do workspace não altera o próprio plano.
As integrações recebidas pelo webhook usam o segredo **da automação**, não uma chave `vsk_live_`. Elas não dependem da habilitação da API pública de CRM ou envio. Mensagens continuam sujeitas à cobrança, saldo e limites existentes.
## Limites do produto [#limites-do-produto]
Não é um importador de workflows n8n. Use o [formato Vensy e contrato para IA](/automations/ai-contract). Não há execução de JavaScript/SQL, loops, instalação de plugins ou conectores OAuth genéricos. Integrações externas usam webhook e o bloco HTTP protegido.
Consulte [os blocos e mapeamentos](/automations/blocks) e [a operação segura](/automations/operations).
# Execuções, limites e recuperação (/automations/operations)
## Estados da execução [#estados-da-execução]
| Estado | O que significa / ação |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| Na fila | Evento persistido aguardando o executor |
| Executando | Worker processando a etapa atual |
| Aguardando | Espera programada, horário, vendedor/canal indisponível, limite do canal ou resposta de envio |
| Concluído | Todas as etapas do caminho terminaram |
| Falha confirmada | Há um erro a corrigir; um administrador pode retomar a etapa dentro do prazo |
| Cancelado | Cancelamento explícito, resposta, opt-out, arquivamento, prazo vencido ou mudança incompatível de estado |
| Revisão necessária | O destino pode ter aceitado a operação, mas a Vensy não conseguiu confirmar o resultado |
Abra **Ver etapas** para consultar saídas, tentativas e motivos. A versão de uma execução não muda quando outra versão é publicada. O histórico de execuções concluídas é retido por 90 dias; não o use como arquivo permanente da integração.
## Pausar, cancelar e retomar [#pausar-cancelar-e-retomar]
**Pausar** impede novas admissões e estaciona execuções existentes. Reative publicando após revisar o rascunho; as execuções antigas continuam usando sua própria versão. A pausa não estende o prazo máximo da execução.
**Cancelar** encerra aquela execução, mas não desfaz contato, negócio, tag, aviso ou mensagem já processados. Uma requisição que já saiu para um provedor não pode ser recolhida.
**Retomar falha confirmada** exige confirmação do administrador. Etapas já concluídas não são repetidas. Uma mensagem comprovadamente rejeitada pode ganhar uma nova tentativa explícita; eventos antigos da fila não podem executar essa nova tentativa. Resultado incerto não oferece retry automático nem o botão de retomar: confira o provedor antes de decidir iniciar outro evento.
Falhas transitórias seguras têm backoff limitado. Uma chamada HTTP mutante não é repetida quando não há garantia de idempotência. Nunca interprete “não recebi resposta” como “o destino não executou”.
## Cotas e cadência [#cotas-e-cadência]
* `automationsEnabled`: habilitação da feature no plano/override.
* `activeAutomations`: número de fluxos ativos; rascunhos e pausados não ocupam esse cap.
* `automationExecutionsPerMonth`: eventos admitidos no mês-calendário UTC, separado do ciclo de cobrança de mensagens.
Simulação, captura de amostra, duplicata e retomada da mesma execução não gastam outra execução mensal. Cancelar não devolve a admissão consumida. O número de mensagens depende dos blocos percorridos; uma execução não equivale a uma mensagem.
O limite administrativo de automações não altera a cadência de WhatsApp. API, campanhas e automações compartilham as proteções por canal já configuradas na Vensy. Mensagens continuam sujeitas a janela oficial, saldo, supressão e disponibilidade. Habilitar a feature não autoriza aumentar esses limites.
Há proteções técnicas adicionais: até 200 definições por workspace (incluindo arquivadas), 80 blocos por definição, até 30 dias por execução e até 5 tentativas automáticas para erros transitórios seguros. Esperas por horário/presença não gastam tentativas de erro.
## Falhas comuns [#falhas-comuns]
| Código/motivo | Como resolver |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `AUTOMATION_WEBHOOK_UNAUTHORIZED` | Confira URL, modo de autenticação, segredo/assinatura ou proteção do formulário |
| `AUTOMATION_IDEMPOTENCY_CONFLICT` | A mesma chave veio com outro conteúdo; corrija a origem, não masque um retry com nova chave |
| `AUTOMATION_NOT_ACTIVE` | Publique/ative o fluxo correto |
| `AUTOMATION_TRIGGER_MISMATCH` | Use webhook somente em fluxo cujo gatilho é Webhook |
| `AUTOMATION_CONTACT_AMBIGUOUS` | Unifique contatos duplicados no CRM |
| `AUTOMATION_TEAM_UNAVAILABLE` | Na política online, não há ninguém com presença recente e capacidade; aguarde ou use conscientemente o rodízio com todos os ativos |
| `AUTOMATION_CHANNEL_UNAVAILABLE` | Reconecte o canal e aguarde a retomada |
| `AUTOMATION_CONSENT_REQUIRED` | Registre/mapeie o consentimento real antes de um envio proativo |
| `AUTOMATION_OFFICIAL_TEMPLATE_REQUIRED` | Use template aprovado fora da janela oficial |
| `AUTOMATION_HTTP_HOST_UNSAFE` | Escolha um domínio HTTPS público; redes privadas não são permitidas |
| `ENTITLEMENT_*` | O app mostra a limitação de plano e a opção de liberação/upgrade |
Administradores devem fornecer apenas os dados necessários ao fluxo e escolher destinos HTTP confiáveis. Credenciais cadastradas são protegidas, mas dados pessoais de entrada e saídas comerciais continuam visíveis a administradores autorizados no histórico. A integração é responsável por capturar a permissão do lead e respeitar sua finalidade.
Referências: [política do WhatsApp Business](https://whatsappbusiness.com/pt-br/policy/?lang=pt_BR) e [orientação OWASP contra SSRF](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html).
# Conectar formulários e webhooks (/automations/webhooks)
Cada automação tem uma URL opaca:
```text
POST https://vensy.com.br/api/v1/webhooks/automations/ID_DO_WEBHOOK
```
Envie um objeto JSON (`application/json`) ou campos simples (`application/x-www-form-urlencoded`), até 64 KiB. Arquivos devem ser representados por URLs, não enviados em multipart ou base64. Dados têm até 12 níveis, arrays de até 200 elementos e strings de até 16.384 caracteres.
O workspace é resolvido pelo webhook autenticado. Não envie `tenantId`, IDs de vendedor, canal ou pipeline para mudar o destino: esses vínculos são escolhidos pelo administrador no editor. O payload é dado, nunca autorização.
## Servidor para servidor: Bearer [#servidor-para-servidor-bearer]
No editor, selecione **Segredo Bearer**. O segredo aparece apenas ao criar/rotacionar; guarde-o no backend do seu site ou cofre da integração. Nunca em HTML, JavaScript público, URL, analytics ou logs.
```bash
curl --request POST "$VENSY_AUTOMATION_URL" \
--header "Authorization: Bearer $VENSY_AUTOMATION_SECRET" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: formulario-envio-000042' \
--data '{
"nome": "Maria Exemplo",
"telefone": "+5511999999999",
"email": "maria@example.com",
"interesse": "Solicitar orçamento",
"whatsappOptIn": true,
"consentimentoRegistradoEm": "2026-08-31T15:00:00Z",
"origem": "Site"
}'
```
O exemplo é fictício. `whatsappOptIn` só deve ser verdadeiro quando o seu formulário registrar uma escolha afirmativa do visitante. No bloco WhatsApp, escolha **Tenho o opt-in registrado em um campo → Formulário / gatilho · whatsappOptIn**. Não digite `true` como valor fixo.
## Assinatura HMAC [#assinatura-hmac]
Selecione **HMAC** para assinar o conteúdo exato no seu servidor:
```ts
import { createHmac } from "node:crypto";
const body = JSON.stringify(eventoDoFormulario);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac("sha256", process.env.VENSY_AUTOMATION_SECRET!)
.update(`${timestamp}.${body}`)
.digest("hex");
const response = await fetch(process.env.VENSY_AUTOMATION_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idPersistenteDoEnvio,
"X-Vensy-Timestamp": timestamp,
"X-Vensy-Signature": `sha256=${signature}`,
},
body,
signal: AbortSignal.timeout(15_000),
});
```
O timestamp usa segundos Unix e tolerância de 5 minutos. Assine os mesmos bytes enviados; reformatar o JSON depois de assinar invalida a assinatura. Em um retry, gere timestamp/assinatura novos e mantenha a mesma chave de evento e conteúdo. O servidor deve estar com o relógio sincronizado.
## Formulário direto do navegador [#formulário-direto-do-navegador]
Quando o site não tem backend, selecione **Formulário público protegido** e cadastre origens exatas, por exemplo `https://www.sualoja.com.br`. Não há wildcard; esquema e subdomínio importam.
A Vensy precisa ter o Turnstile configurado e autorizado para o hostname do site. O editor informa a site key pública. Renderize o widget com `data-action="vensy_lead"` e envie o token com o formulário. A chave secreta do Turnstile permanece apenas na Vensy.
```html
```
Quando a caixa não é marcada, o navegador não envia `whatsappOptIn`; a automação interpreta isso como ausência de consentimento e bloqueia o contato proativo. Não marque a caixa previamente e mantenha no seu sistema a evidência da escolha e sua data.
```js
const form = document.querySelector("#lead-form");
const status = document.querySelector("#lead-status");
let submission = null;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const fields = Object.fromEntries(new FormData(form));
const captcha = fields["cf-turnstile-response"];
delete fields["cf-turnstile-response"];
// Uma nova edição é um novo evento; retries do mesmo envio mantêm a chave.
const fingerprint = JSON.stringify(fields);
if (submission?.fingerprint !== fingerprint) {
submission = { fingerprint, eventId: crypto.randomUUID() };
}
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
try {
const response = await fetch("URL_PUBLICA_DA_AUTOMACAO", {
method: "POST",
credentials: "omit",
headers: { "Content-Type": "application/json", "X-Turnstile-Token": captcha },
body: JSON.stringify({ ...fields, eventId: submission.eventId }),
});
if (!response.ok) throw new Error("Não foi possível confirmar a solicitação.");
status.textContent = "Recebemos sua solicitação.";
form.reset();
submission = null;
} catch {
status.textContent = "Falha ao confirmar. Tente novamente sem alterar os dados.";
} finally {
button.disabled = false;
window.turnstile?.reset(); // tokens são curtos e de uso único
}
});
```
Não substitua Turnstile por uma chave Bearer no frontend. Origin/CORS sozinho não autentica um formulário. O servidor verifica o token, hostname e action; sem proteção válida não admite o evento. Quem usa o modo público aceita uma entrada pública protegida contra abuso, não uma prova de identidade do visitante.
## Idempotência e resposta [#idempotência-e-resposta]
Envie `Idempotency-Key` (ou `X-Idempotency-Key`) ou um campo `eventId`: chave de 8–128 caracteres, estável para o mesmo envio. Não use só o telefone; a mesma pessoa pode gerar novas oportunidades.
```json
{ "runId": "ID_DA_EXECUCAO", "duplicate": false, "status": "QUEUED" }
```
`202` significa **aceito e persistido**, não mensagem enviada. Se a Vensy estiver instável no momento do envio, o evento fica guardado e a resposta é `{ "queued": true }`, sem `runId`. A execução começa assim que o serviço volta, e não é preciso reenviar. Mesmo evento e dados devolvem `duplicate: true` e não consomem outra execução; mesma chave com outros dados retorna `409`. A deduplicação de entrada acompanha a retenção de execuções (90 dias após conclusão); não recicle chaves antigas.
Em timeout/erro transitório, use backoff com jitter e a mesma chave. Respeite `Retry-After` em `429`; não repita `400`/`401`/`403` sem corrigir a causa. O limite de entrada é 60 requisições/minuto por IP, compartilhado entre URLs de automação; cotas do workspace são verificadas separadamente.
## Capturar amostra sem efeitos [#capturar-amostra-sem-efeitos]
Clique **Aguardar evento de teste** e envie `X-Vensy-Test-Token` com o token temporário mostrado. A captura aceita um evento por até 5 minutos, mesmo em rascunho, e responde `200 { "captured": true }`. Ela não cria contato, negócio, execução nem mensagem. Gere outra captura para repetir e use dados fictícios. Tokens de captura não devem ser mantidos no site publicado.
Referências: [verificação server-side do Turnstile](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/).
# Formatos de mensagem (/messages)
O endpoint `POST /api/v1/messages` aceita texto, imagem, áudio, vídeo, documento e localização. Templates oficiais também podem ser enviados pelo endpoint dedicado.
Mensagens livres pelo canal não oficial.
Arquivos por URL ou Base64.
Coordenadas, nome e endereço.
Templates aprovados para WhatsApp Cloud.
## Seleção de canal [#seleção-de-canal]
`channelCode` é opcional para disparos em canais não oficiais. Quando omitido, a Vensy seleciona um cartucho apto pela rotação configurada no workspace.
Para WhatsApp Cloud, informe um template aprovado. Quando existe mais de um canal oficial ativo, use `channelCode` para tornar a seleção determinística.
Em um canal não oficial, `text` tem precedência. Em um canal Cloud, o template é obrigatório e é enviado no formato oficial da Meta.
# Localização (/messages/location)
Use `type: "location"` e informe latitude e longitude:
```json
{
"to": "+5511999999999",
"type": "location",
"location": {
"latitude": -23.55052,
"longitude": -46.633308,
"name": "Unidade Centro",
"address": "Praça da Sé, São Paulo - SP"
}
}
```
`latitude` aceita valores de -90 a 90 e `longitude`, de -180 a 180. `name` e `address` melhoram a visualização no WhatsApp, mas são opcionais.
# Mídia (/messages/media)
Tipos aceitos: `image`, `audio`, `video` e `document`.
## Por URL [#por-url]
```json
{
"to": "+5511999999999",
"type": "image",
"mediaUrl": "https://cdn.example.com/catalogo.jpg",
"caption": "Catálogo atualizado"
}
```
A URL precisa ser acessível pelo servidor da Vensy. Prefira HTTPS e mantenha o arquivo disponível até a API concluir o download.
## Por Base64 [#por-base64]
```json
{
"to": "+5511999999999",
"type": "document",
"mediaBase64": "JVBERi0xLjQKJc...",
"mediaMimeType": "application/pdf",
"mediaFileName": "proposta.pdf",
"caption": "Sua proposta comercial"
}
```
Envie somente o conteúdo Base64, sem o prefixo `data:`. Para payloads grandes, URL costuma ser mais eficiente e observável.
Mensagens de mídia exigem `mediaUrl` ou `mediaBase64`. Para Base64, inclua também o MIME type e, em documentos, um nome de arquivo útil.
# Templates do WhatsApp Oficial (/messages/templates)
Use `POST /api/v1/messages/template` para um contrato explícito de template oficial.
```json
{
"to": "+5511999999999",
"channelCode": "whatsapp-oficial",
"template": {
"name": "confirmacao_pedido",
"language": "pt_BR"
},
"bodyVariables": ["Maria", "#1234"]
}
```
Você também pode identificar o template pelo campo `template.id`. Ao usar nome, `template.name` e `template.language` são obrigatórios.
`bodyVariables`, `headerParameter` e `buttonParameters` são irmãos de `template`; eles não ficam dentro do objeto `template`.
## Header de texto [#header-de-texto]
```json
{
"headerParameter": {
"kind": "text",
"text": "Pedido #1234"
}
}
```
## Header de mídia [#header-de-mídia]
```json
{
"headerParameter": {
"kind": "media",
"format": "DOCUMENT",
"url": "https://cdn.example.com/pedido-1234.pdf",
"mimeType": "application/pdf",
"fileName": "pedido-1234.pdf"
}
}
```
## Botões dinâmicos [#botões-dinâmicos]
```json
{
"buttonParameters": [
{
"index": 0,
"subType": "url",
"value": "pedido-1234"
}
]
}
```
Os valores devem seguir exatamente a ordem e o formato do template aprovado. Veja [Enviar template oficial](/api-reference/send-template) para todos os campos.
# Texto (/messages/text)
Use `type: "text"` ou omita `type`, pois texto é o padrão.
```json
{
"to": "+5511999999999",
"type": "text",
"text": "Seu pedido #1234 saiu para entrega."
}
```
O campo `text` aceita até 4.096 caracteres. O destino deve estar no formato E.164, com `+`, código do país e DDD.
Para escolher explicitamente um canal não oficial:
```json
{
"to": "+5511999999999",
"channelCode": "cartucho-comercial-01",
"text": "Posso ajudar com alguma dúvida?"
}
```
Consulte [Enviar mensagem](/api-reference/send-message) para o schema completo.
# Processamento assíncrono (/reliability/asynchronous)
Envie o header abaixo para optar pelo processamento assíncrono:
```http
Prefer: respond-async
```
A Vensy grava a intenção de envio e o evento de outbox antes de responder. O relayer entrega a mensagem com retry e backoff.
Uma aceitação assíncrona usa `202`:
```json
{
"messageId": null,
"providerMessageId": null,
"outreachId": "cm_outreach_123",
"firingChannelId": "cm_channel_123",
"conversationId": null,
"status": "queued",
"createdAt": "2026-08-27T18:30:00.000Z",
"idempotencyReplayed": false,
"apiKeyId": "cm_key_123"
}
```
`providerMessageId` ainda não existe nesse momento. Se você precisa correlacionar o status por esse identificador, consulte o registro de envios no app após o relayer processar a fila.
Use o modo síncrono quando a integração precisa do ID do provedor na resposta imediata; use o assíncrono para maior vazão e menor acoplamento à latência externa.
# Idempotência (/reliability/idempotency)
Inclua `Idempotency-Key` em todo `POST`. A chave deve ter de 8 a 128 caracteres e ser única para cada mensagem lógica.
```http
Idempotency-Key: order-1234-shipping-notice-v1
```
Se a primeira resposta se perder, repita a requisição com a mesma chave e o mesmo payload. Quando a operação já tiver sido concluída, a API devolve o resultado armazenado com:
```json
{
"idempotencyReplayed": true
}
```
Associe a chave ao evento de negócio, não à tentativa HTTP. Uma nova mensagem exige uma nova chave.
O alias legado `X-Idempotency-Key` continua aceito, mas novas integrações devem usar o header padrão.
# Integração confiável (/reliability)
Uma integração de produção deve assumir que redes falham, respostas podem se perder e provedores têm latência variável.
Repita tentativas sem duplicar disparos.
Reduza a latência e deixe a entrega com o relayer.
Acompanhe sent, delivered, read e failed.
Classifique erros antes de tentar novamente.
## Estratégia recomendada [#estratégia-recomendada]
1. Gere uma `Idempotency-Key` estável para cada mensagem lógica.
2. Use `Prefer: respond-async` em cargas de maior volume.
3. Persista `providerMessageId`, `outreachId` e o status retornado.
4. Consulte o status com backoff quando o resultado de negócio depender da entrega.
5. Repita apenas erros transitórios e respeite `Retry-After` em respostas `429`.
# Entregas, falhas e reenvio (/webhooks/deliveries)
## O que conta como sucesso [#o-que-conta-como-sucesso]
Qualquer resposta HTTP **2xx** no `POST` da entrega é sucesso. Qualquer outra coisa conta como falha: `3xx` (redirect não é seguido), `4xx`, `5xx`, timeout (10 segundos) e erro de rede/DNS/TLS. O corpo da sua resposta não é interpretado — só o status.
## Retentativas [#retentativas]
Uma entrega que falha é tentada de novo com backoff exponencial, por até **24 horas** desde a primeira tentativa (ou desde um reenvio manual, que abre uma janela nova — veja abaixo):
| Tentativa | Espera até a próxima |
| ------------------- | ----------------------------- |
| 1ª falha | 2s |
| 2ª falha | 4s |
| 3ª falha | 8s |
| 4ª falha | 16s |
| 5ª falha | 32s |
| 6ª falha | 1min |
| 7ª falha | 2min |
| 8ª falha | 5min |
| 9ª falha | 10min |
| 10ª falha | 20min |
| 11ª falha | 30min |
| 12ª falha em diante | 1h (repete até completar 24h) |
Depois de 24h sem sucesso, a entrega vira `FAILED` e só volta a ser tentada com um [reenvio manual](#reenvio-manual).
## Desativação do endpoint por inatividade [#desativação-do-endpoint-por-inatividade]
* Na **primeira falha** depois de um período de sucesso, o endpoint começa a "falhar" (fica marcado internamente, sem mudar de status) e um e-mail é enviado aos membros OWNER/ADMIN do workspace avisando que o endpoint está com problema.
* Se o endpoint passar **5 dias corridos sem nenhuma entrega bem-sucedida**, ele vira `DISABLED_FAILING`: todas as entregas pendentes são canceladas e um segundo e-mail avisa a desativação.
* Um workspace sem acesso operacional (assinatura suspensa, trial expirado etc.) tem as entregas **pausadas sem gastar tentativa** até o acesso ser restaurado — isso não conta como falha para o prazo de 5 dias.
* Para reativar, use o botão "Reativar" na UI ou `POST /endpoints/:id/enable`. O endpoint volta para `ACTIVE` e o contador de inatividade zera no próximo sucesso.
## Log de entregas e retenção [#log-de-entregas-e-retenção]
Cada endpoint tem um log de entregas com as tentativas, status HTTP recebido, erro (quando houver) e duração. A tela faz polling a cada 10s para refletir o worker em andamento.
* **Metadados** (status, tentativas, timestamps) ficam por **30 dias**.
* **Corpo da requisição e da resposta** ficam por **7 dias**; depois disso o conteúdo é anulado, mas a linha do log continua existindo com os metadados.
## Reenvio manual [#reenvio-manual]
Reenviar uma entrega (botão "Reenviar" ou `POST /deliveries/:id/resend`) **reusa o corpo já gravado** — por isso só funciona enquanto o corpo existir (até 7 dias após a entrega original). O reenvio abre uma **janela nova de 24h** de retentativas e não some com o histórico de tentativas anteriores.
Condições para reenviar:
* o endpoint precisa estar `ACTIVE`;
* a entrega precisa estar num estado terminal (`SUCCEEDED`, `FAILED` ou `CANCELLED`) — não dá para reenviar uma entrega que ainda está `PENDING`/`SENDING`, o worker já está cuidando dela.
## Evento de teste [#evento-de-teste]
`POST /endpoints/:id/test` dispara um evento de exemplo do tipo escolhido, **assinado de verdade** com o segredo do endpoint, com `"test": true` no envelope — útil para validar a integração sem esperar o evento real acontecer.
## Gerenciar pela API [#gerenciar-pela-api]
Para integradores (n8n, Make, scripts próprios), toda a gestão de endpoints e entregas também está disponível por API key, com o escopo `webhooks:manage`:
```text
https://vensy.com.br/api/v1/integrations/webhooks
```
Estas rotas ainda não aparecem no [contrato OpenAPI público](https://vensy.com.br/api/v1/openapi.json) nem na [Referência da API](/api-reference/send-message) gerada a partir dele — a tabela abaixo é o contrato até essa referência ser publicada.
### Endpoints [#endpoints]
| Método | Caminho | Uso |
| -------- | ----------------------------------- | ------------------------------------------------------------------------ |
| `POST` | `/endpoints` | Cria um endpoint. Devolve o segredo em texto puro **uma única vez**. |
| `GET` | `/endpoints` | Lista os endpoints do workspace. |
| `GET` | `/endpoints/{id}` | Detalhe de um endpoint (sem o segredo). |
| `PATCH` | `/endpoints/{id}` | Atualiza URL, descrição, eventos, headers ou status (`ACTIVE`/`PAUSED`). |
| `DELETE` | `/endpoints/{id}` | Exclui o endpoint. |
| `POST` | `/endpoints/{id}/regenerate-secret` | Gera um segredo novo e invalida o anterior na hora. |
| `POST` | `/endpoints/{id}/test` | Envia um evento de teste assinado. |
| `POST` | `/endpoints/{id}/enable` | Reativa um endpoint `DISABLED_FAILING`. |
| `GET` | `/endpoints/{id}/deliveries` | Log de entregas do endpoint. |
### Entregas [#entregas]
| Método | Caminho | Uso |
| ------ | ------------------------- | ------------------------------------------ |
| `GET` | `/deliveries/{id}` | Detalhe de uma entrega, com as tentativas. |
| `POST` | `/deliveries/{id}/resend` | Reenvia (reusa o corpo gravado). |
### Catálogo [#catálogo]
| Método | Caminho | Uso |
| ------ | -------------- | -------------------------------------------------- |
| `GET` | `/event-types` | Lista os tipos de evento disponíveis para assinar. |
### Criar um endpoint [#criar-um-endpoint]
```bash
curl -X POST https://vensy.com.br/api/v1/integrations/webhooks/endpoints \
-H "Authorization: Bearer vsk_live_SUA_CHAVE" \
-H "Content-Type: application/json" \
-d '{
"url": "https://meu-n8n.exemplo.com/webhook/vensy",
"description": "Fluxo n8n de negócios",
"eventTypes": ["deal.*", "contact.created"],
"headers": { "X-Minha-Chave": "valor-secreto" }
}'
```
A resposta traz `secret` em texto puro — a única vez que ele aparece. `eventTypes` aceita até 50 entradas por endpoint; `headers` aceita até 10 headers customizados.
## Erros [#erros]
| Código | Status | Quando acontece |
| -------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
| `WEBHOOK_ENDPOINT_LIMIT_REACHED` | `409` | O workspace já tem o número máximo de endpoints (10 por padrão). |
| `WEBHOOK_URL_FORBIDDEN` | `400` | A URL não é HTTPS, ou resolve para um host/IP privado (anti-SSRF). |
| `WEBHOOK_EVENT_TYPE_INVALID` | `400` | Um ou mais itens de `eventTypes` não existem no catálogo (nem como tipo exato nem como família válida). |
| `WEBHOOK_HEADER_INVALID` | `400` | Um header customizado tem nome inválido, nome reservado (`webhook-*`, `content-type`...) ou valor com quebra de linha. |
| `WEBHOOK_ENDPOINT_NOT_FOUND` | `404` | O endpoint não existe (ou pertence a outro workspace). |
| `WEBHOOK_DELIVERY_NOT_FOUND` | `404` | A entrega não existe (ou pertence a outro workspace). |
| `WEBHOOK_DELIVERY_BODY_EXPIRED` | `409` | Tentou reenviar uma entrega cujo corpo já passou dos 7 dias de retenção. |
| `WEBHOOK_ENDPOINT_NOT_ACTIVE` | `409` | Tentou reenviar uma entrega de um endpoint que não está `ACTIVE`. |
| `WEBHOOK_DELIVERY_IN_PROGRESS` | `409` | Tentou reenviar uma entrega que ainda não chegou a um estado terminal. |
Consulte também o [catálogo geral de erros](/errors-and-limits) para os códigos comuns a toda a API (`API_KEY_INVALID`, `API_KEY_SCOPE_REQUIRED`, validação, etc.).
# Catálogo de eventos (/webhooks/events)
Um endpoint assina eventos por **tipo exato**, por **família inteira** (`familia.*`) ou **tudo** (`*`). Por exemplo, assinar `deal.*` equivale a assinar `deal.created`, `deal.stage_changed`, `deal.won` e `deal.lost` ao mesmo tempo; um tipo novo que a Vensy adicionar depois dentro de uma família já assinada por curinga passa a chegar automaticamente.
## message.\* [#message]
| Evento | Quando dispara |
| ------------------- | --------------------------------------------------------- |
| `message.received` | Mensagem inbound recebida em tempo real (não backfill). |
| `message.sent` | Mensagem outbound enviada ao provedor. |
| `message.delivered` | Mensagem outbound confirmada como entregue pelo provedor. |
| `message.read` | Mensagem outbound confirmada como lida pelo contato. |
| `message.failed` | Envio de mensagem falhou no provedor. |
## conversation.\* [#conversation]
| Evento | Quando dispara |
| ------------------------------ | -------------------------------------------------- |
| `conversation.created` | Nova conversa criada em tempo real (não backfill). |
| `conversation.assigned` | Conversa atribuída a um agente. |
| `conversation.closed` | Conversa encerrada. |
| `conversation.reopened` | Conversa reaberta depois de encerrada. |
| `conversation.queued` | Conversa voltou para a fila, sem atribuição. |
| `conversation.channel_changed` | O canal da conversa foi trocado. |
## contact.\* [#contact]
| Evento | Quando dispara |
| ------------------ | --------------------------------------------------------------------------- |
| `contact.created` | Novo contato criado no CRM. |
| `contact.updated` | Campos do contato alterados — os valores anteriores vêm em `data.previous`. |
| `contact.archived` | Contato arquivado. |
| `contact.deleted` | Contato excluído. O payload traz só ids (LGPD), sem dados pessoais. |
`data.object` de contato segue o mesmo formato público usado pela [API do CRM](/crm#criar-um-contato) — os mesmos campos que você já recebe num `GET /contacts/{id}`.
## deal.\* [#deal]
| Evento | Quando dispara |
| -------------------- | ------------------------------------ |
| `deal.created` | Novo negócio criado no funil do CRM. |
| `deal.stage_changed` | Negócio mudou de etapa no funil. |
| `deal.won` | Negócio marcado como ganho. |
| `deal.lost` | Negócio marcado como perdido. |
`data.object` de negócio segue o mesmo formato público usado pela [API do CRM](/crm#endpoints) (`GET /deals/{id}`).
## channel.\* [#channel]
| Evento | Quando dispara |
| ------------------------ | ----------------------------------------------------------------------------- |
| `channel.status_changed` | O status de conexão do canal mudou (ex.: conectado, degradado, desconectado). |
`data.object` de canal traz os campos públicos do recurso canal (id, provedor, nome e status de conexão) — não é o mesmo formato detalhado usado internamente na tela de Canais.
## campaign.\* [#campaign]
| Evento | Quando dispara |
| ------------------------- | -------------------------------------------------------------- |
| `campaign.status_changed` | Status da campanha mudou (ex.: pausada, retomada, concluída). |
| `campaign.lead_replied` | Um lead da campanha respondeu. |
| `campaign.lead_failed` | Um lead da campanha falhou ou foi pulado (envio ou validação). |
`data.object` de campanha e de lead de campanha traz os campos públicos desses recursos (id, status, motivo da pausa/falha quando houver).
## Curingas [#curingas]
| Assinatura | Equivale a |
| ----------- | ---------------------------------------- |
| `message.*` | Todos os 5 eventos de `message` |
| `*` | Todos os 23 eventos de todas as famílias |
A lista oficial e sempre atualizada está disponível em `GET /api/v1/webhook-event-types` (sessão) ou `GET /api/v1/integrations/webhooks/event-types` (API key) — use-a ao montar a tela de seleção de eventos na sua própria integração, em vez de fixar esta lista em código.
# Visão geral (/webhooks)
A API pública e o CRM cobrem o **puxar**: sua integração pede dados quando precisa. Os webhooks de eventos cobrem o **empurrar**: você cadastra uma URL e a Vensy faz `POST` nela sempre que algo relevante acontece — uma mensagem chega, uma conversa é fechada, um negócio muda de etapa. É o caminho recomendado para ligar a Vensy no n8n, no Make ou em qualquer outro orquestrador.
Esta seção é sobre a Vensy **enviando** eventos para você. Se o que você quer é o contrário — um formulário do seu site ou outro sistema **disparando** uma automação na Vensy — veja [Conectar formulários e webhooks](/automations/webhooks).
## Conceitos [#conceitos]
| Termo | O que é |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Endpoint** | Uma URL HTTPS cadastrada no workspace, com a lista de eventos que ela assina, um segredo e headers próprios. |
| **Evento** | Algo que aconteceu no workspace (`message.received`, `deal.won`...), representado por um envelope com `id`, `type`, `occurredAt` e os dados do recurso. |
| **Entrega** | Uma tentativa de `POST` de um evento para um endpoint. Um evento pode gerar várias entregas (uma por endpoint que o assina) e uma entrega pode ter várias tentativas (retries). |
| **Tentativa** | Uma chamada HTTP individual de uma entrega, com o status recebido (ou o erro) registrado no log. |
## Garantias [#garantias]
* **Pelo menos uma vez, sem ordem garantida.** A mesma entrega pode chegar mais de uma vez (reenvio manual, retry que teve sucesso do lado do seu servidor mas a resposta se perdeu) e dois eventos podem chegar fora de ordem. Use `data.object` e `occurredAt` para decidir o estado final, nunca a ordem de chegada: compare o `occurredAt` do evento recebido com o que você já tem salvo e descarte o que chegar mais antigo.
* **`webhook-id` é estável** entre tentativas e entre endpoints — use-o para deduplicar (ex.: uma tabela de ids já processados, com TTL de alguns dias).
* **Nunca registra webhook sobre grupos do WhatsApp nem sobre backfill/importação histórica.** Só eventos em tempo real de conversas 1:1 geram entrega. Uma importação em massa de contatos gera um evento por registro (`contact.created` repetido), nunca um evento único de lote.
## Limites [#limites]
* **10 endpoints por workspace** (ajustável pela Vensy, não autoatendimento).
* **Só HTTPS.** Não há IP fixo de saída — os nós do cluster são efêmeros — então não dá para autenticar por allowlist de IP; autentique pela [assinatura](/webhooks/signing).
* Disponível em **todos os planos**, sem necessidade de habilitar nada.
## Próximos passos [#próximos-passos]
Monte o fluxo mais comum: nó Webhook + endpoint na Vensy.
Verifique que a requisição realmente veio da Vensy.
O envelope, o versionamento e exemplos reais.
Os 23 tipos disponíveis, por família.
Retentativas, desativação e a API de gestão.
# Primeiros passos com n8n (/webhooks/n8n)
Este guia assume o caso mais comum: você quer reagir a eventos da Vensy dentro de um fluxo n8n (criar uma linha numa planilha quando um negócio é ganho, notificar um canal quando uma conversa for atribuída, etc.).
## 1. Crie o nó Webhook no n8n [#1-crie-o-nó-webhook-no-n8n]
Adicione um nó **Webhook** como gatilho do fluxo, com método `POST`. O n8n mostra duas URLs:
* **URL de teste**: só funciona enquanto você estiver com o editor aberto e escutando ("Listen for test event"). Use-a para cadastrar o endpoint na Vensy e mandar o primeiro evento de teste.
* **URL de produção**: fica ativa só depois que o fluxo é **ativado** (toggle no canto superior). É essa URL que deve ficar cadastrada na Vensy depois que o fluxo for para produção — trocar a URL de teste pela de produção é o erro mais comum nesse primeiro contato.
## 2. Cadastre o endpoint na Vensy [#2-cadastre-o-endpoint-na-vensy]
Pela UI: **Configurações → Integrações e ferramentas → Webhooks**, disponível para quem tem papel OWNER ou ADMIN no workspace. Informe:
* a URL do nó Webhook do n8n (teste, no primeiro momento);
* os eventos que você quer assinar, agrupados por família (ex.: marque `deal.*` inteiro, ou só `deal.won`);
* headers customizados, se o seu fluxo exigir um (ex.: um Header Auth adicional — veja a seção abaixo).
Ao salvar, o **segredo é mostrado uma única vez**. Guarde-o já — ele não aparece de novo, só é possível gerar um novo (o que invalida o anterior na hora). O mesmo cadastro pode ser feito pela [API de gestão](/webhooks/deliveries#gerenciar-pela-api), com uma chave de API de escopo `webhooks:manage`.
## 3. Envie o evento de teste [#3-envie-o-evento-de-teste]
Depois de criar o endpoint, use o botão "Testar" (ou `POST /endpoints/:id/test` pela API) para mandar um evento de exemplo, assinado de verdade, com `"test": true` no envelope. Confirme no n8n (no painel do nó Webhook, com "Listen for test event" ativo) que o payload chegou como esperado.
## 4. Filtre por tipo no fluxo [#4-filtre-por-tipo-no-fluxo]
Um endpoint pode assinar vários tipos de evento ao mesmo tempo — o campo `type` do envelope é o que diferencia um `message.received` de um `deal.won` chegando no mesmo nó Webhook. Use um nó **Switch** ou **If** logo depois do Webhook, comparando `{{ $json.type }}`, para rotear cada tipo para o ramo certo do fluxo.
## 5. Evite loop com automações da Vensy [#5-evite-loop-com-automações-da-vensy]
Se o seu fluxo n8n também **escreve de volta** na Vensy (ex.: atualiza um campo do contato via API pública), cuidado com o loop: essa escrita gera um novo evento (`contact.updated`), que volta para o seu webhook, que escreve de novo. Use o campo `actor` do envelope para distinguir origem:
```json
{
"actor": { "type": "api_key", "id": "...", "name": "n8n produção" }
}
```
Se o seu fluxo só deve reagir a mudanças feitas por humanos na UI (`actor.type: "user"`) ou pelo contato (`actor.type: "contact"`), descarte eventos cujo `actor.type` seja `api_key` com o nome da própria integração, ou `automation`.
## Autenticação adicional (opcional) [#autenticação-adicional-opcional]
Além da [assinatura Standard Webhooks](/webhooks/signing) — que é sempre enviada e deveria ser a sua verificação principal — você pode adicionar um **Header Auth** no nó Webhook do n8n (um header fixo tipo `X-Minha-Chave: valor-secreto`) e cadastrar esse mesmo header na Vensy como header customizado do endpoint. Isso é defesa em profundidade, não substitui verificar a assinatura.
# Formato do evento (/webhooks/payload)
Todo evento chega no mesmo envelope, independente do tipo:
```json
{
"id": "evt_01j8x3z9f9a2e8n4qf3r6h8g1p",
"type": "deal.stage_changed",
"version": 1,
"occurredAt": "2026-10-01T18:22:07.123456Z",
"workspace": { "id": "ws_9f2a...", "slug": "acme" },
"actor": { "type": "api_key", "id": "key_7a1b...", "name": "n8n produção" },
"data": {
"object": { "...snapshot no formato público...": "" },
"previous": { "stageId": "stg_old..." }
},
"test": false
}
```
| Campo | Descrição |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Id do evento. Estável entre tentativas e endpoints — use para deduplicar. |
| `type` | Tipo do evento (ver [catálogo](/webhooks/events)). |
| `version` | Versão do contrato do envelope. Sempre `1` hoje. |
| `occurredAt` | Quando o evento aconteceu no domínio (não quando foi entregue). Use para ordenar/desempatar — a entrega não garante ordem. |
| `workspace` | Id e slug do workspace dono do evento. |
| `actor` | Quem causou o evento: `{ type: "user" \| "contact" \| "automation" \| "campaign" \| "api_key" \| "system", id?, name? }`. Com `type: "user"`, o `id` é o da membership do usuário no workspace (o mesmo de `assignee.membershipId`); com `"contact"`, o id do contato; com `"api_key"`, o id da chave. Sem marcação específica no caminho que originou a mudança, `type` é `"system"`. |
| `data.object` | O recurso no formato público da API (o mesmo formato de contato/empresa/negócio da API do CRM; mensagem, conversa, canal e campanha têm formato próprio, novo, feito para o webhook). |
| `data.previous` | Só em eventos `*.updated`/`*_changed`: os campos que mudaram, com o valor **anterior**. Ausente quando não se aplica. |
| `test` | `true` só no evento disparado manualmente pelo botão "Testar". Omitido (equivalente a `false`) em eventos reais. |
`campo omitido` ≠ `campo null`: trate os dois como "sem valor" ao consumir o payload; não assuma que um campo estará sempre presente.
## Política de versão [#política-de-versão]
Mudanças no contrato só são **aditivas**: um campo novo pode aparecer a qualquer momento em `data.object`, `data.previous` ou no próprio envelope, sem aviso prévio — sua integração deve ignorar campos desconhecidos. Renomear ou remover um campo existente, ou mudar o tipo de um tipo de evento, é mudança incompatível: sai como `version: 2` (ou um tipo de evento novo), com um período de convivência antes de desligar o formato anterior.
## Exemplo: `message.received` [#exemplo-messagereceived]
```json
{
"id": "evt_01j8x40htqb3nxrqz6tftg37z2",
"type": "message.received",
"version": 1,
"occurredAt": "2026-10-01T18:22:07.123456Z",
"workspace": { "id": "ws_9f2a...", "slug": "acme" },
"actor": { "type": "contact", "id": "cta_4f21..." },
"data": {
"object": {
"id": "msg_example",
"conversationId": "conv_example",
"contact": {
"id": "cta_example",
"name": "Maria Oliveira",
"phone": "+5511999990000"
},
"channel": {
"id": "ch_example",
"provider": "WHATSAPP",
"name": "Vendas"
},
"direction": "INBOUND",
"contentType": "TEXT",
"text": "Olá! Gostaria de saber mais sobre o produto.",
"media": null,
"sendStatus": null,
"deliveryStatus": "sent",
"sendError": null,
"createdAt": "2026-10-01T18:22:07.123456Z"
}
}
}
```
Uma mensagem com mídia traz `media` preenchido, com uma URL assinada que expira em 7 dias (ver [assinatura e segurança](/webhooks/signing#links-de-mídia-expiram-em-7-dias)):
```json
"media": {
"kind": "image",
"mimeType": "image/jpeg",
"size": 48213,
"fileName": "foto.jpg",
"url": "https://vensy.com.br/api/v1/webhook-media/ast_7f3c...?t=ws_9f2a...&exp=1738598400&sig=..."
}
```
## Exemplo: `conversation.closed` [#exemplo-conversationclosed]
```json
{
"id": "evt_01j8x42n7wf9b3xqk1v5t8m6c4",
"type": "conversation.closed",
"version": 1,
"occurredAt": "2026-10-01T18:40:00.000000Z",
"workspace": { "id": "ws_9f2a...", "slug": "acme" },
"actor": { "type": "user", "id": "mem_8a21...", "name": "Ana Souza" },
"data": {
"object": {
"id": "conv_example",
"status": "CLOSED",
"channel": { "id": "ch_example", "provider": "WHATSAPP", "name": "Vendas" },
"contact": {
"id": "cta_example",
"name": "Maria Oliveira",
"phone": "+5511999990000"
},
"assignee": { "membershipId": "mem_8a21...", "name": "Ana Souza" },
"department": { "id": "dep_example", "name": "Vendas" },
"closureReason": "RESOLVED",
"createdAt": "2026-10-01T18:22:07.123456Z",
"closedAt": "2026-10-01T18:40:00.000000Z"
}
}
}
```
# Assinatura e segurança (/webhooks/signing)
Toda entrega carrega três headers do padrão [Standard Webhooks](https://www.standardwebhooks.com/):
```http
webhook-id: evt_01j...
webhook-timestamp: 1738425600
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
```
* **`webhook-id`**: o id do evento. É o mesmo em todas as tentativas e em todos os endpoints — use-o para deduplicar.
* **`webhook-timestamp`**: segundos desde epoch, no momento em que a entrega foi assinada (muda a cada tentativa).
* **`webhook-signature`**: `v1,` seguido da assinatura em base64.
## Como a assinatura é calculada [#como-a-assinatura-é-calculada]
O conteúdo assinado é a concatenação `{webhook-id}.{webhook-timestamp}.{corpo bruto da requisição}` — exatamente os bytes enviados, sem reformatar o JSON. A chave é o segredo do endpoint (`whsec_...`, mostrado uma única vez na criação) decodificado de base64 **sem** o prefixo `whsec_`:
```
assinatura = "v1," + base64(HMAC-SHA256(base64decode(segredo sem "whsec_"), "{id}.{timestamp}.{corpo}"))
```
Use uma função de comparação resistente a timing attack (`crypto.timingSafeEqual` em Node, `hmac.compare_digest` em Python). Comparar strings com `===`/`==` vaza o tempo de execução e permite descobrir o segredo byte a byte.
## Verificação em Node.js [#verificação-em-nodejs]
```js
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyVensyWebhook({ secret, headers, rawBody, toleranceSeconds = 300 }) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const received = headers["webhook-signature"];
if (!id || !timestamp || !received) {
throw new Error("headers de assinatura ausentes");
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
throw new Error("timestamp fora da tolerância (possível replay)");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signedContent = `${id}.${timestamp}.${rawBody}`;
const expectedSignature = createHmac("sha256", key)
.update(signedContent, "utf8")
.digest("base64");
const expectedHeader = `v1,${expectedSignature}`;
const expectedBuf = Buffer.from(expectedHeader);
const receivedBuf = Buffer.from(received);
const valid =
expectedBuf.length === receivedBuf.length &&
timingSafeEqual(expectedBuf, receivedBuf);
if (!valid) throw new Error("assinatura inválida");
}
```
Use `rawBody` — o corpo **bruto** da requisição, antes de qualquer `JSON.parse`. Em frameworks que já fazem parse automático (Express com `express.json()`, por exemplo), configure um `verify` callback para capturar os bytes originais antes do parse.
## Verificação em Python [#verificação-em-python]
```python
import hashlib
import hmac
import base64
import time
def verify_vensy_webhook(secret: str, headers: dict, raw_body: bytes, tolerance_seconds: int = 300) -> None:
event_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
received = headers["webhook-signature"]
if abs(time.time() - int(timestamp)) > tolerance_seconds:
raise ValueError("timestamp fora da tolerância (possível replay)")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{event_id}.{timestamp}.".encode() + raw_body
expected_signature = base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode()
expected_header = f"v1,{expected_signature}"
if not hmac.compare_digest(expected_header, received):
raise ValueError("assinatura inválida")
```
## Verificação no nó Code do n8n [#verificação-no-nó-code-do-n8n]
Quando não dá para validar fora do n8n, um nó **Code** logo após o Webhook (antes de qualquer lógica de negócio) resolve:
```js
const crypto = require('crypto');
const SECRET = $env.VENSY_WEBHOOK_SECRET; // configure como variável de ambiente do n8n
const headers = $input.first().json.headers;
const rawBody = $input.first().json.body; // ver nota abaixo sobre corpo bruto
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const received = headers['webhook-signature'];
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
throw new Error('timestamp fora da tolerância');
}
const key = Buffer.from(SECRET.replace(/^whsec_/, ''), 'base64');
const signedContent = `${id}.${timestamp}.${JSON.stringify(rawBody)}`;
const expected = 'v1,' + crypto.createHmac('sha256', key).update(signedContent, 'utf8').digest('base64');
const expectedBuf = Buffer.from(expected);
const receivedBuf = Buffer.from(received);
const valid = expectedBuf.length === receivedBuf.length && crypto.timingSafeEqual(expectedBuf, receivedBuf);
if (!valid) {
throw new Error('assinatura inválida');
}
return $input.all();
```
O nó Webhook do n8n faz o parse do corpo antes de você conseguir os bytes originais, e `JSON.stringify` não reproduz byte a byte o corpo que a Vensy assinou (ordem de chaves, espaçamento). Para verificação 100% fiel, prefira validar num backend seu antes de repassar ao n8n, ou habilite "Raw Body" nas opções do nó Webhook (quando disponível na sua versão) para ter acesso ao corpo bruto.
## Proteção contra replay [#proteção-contra-replay]
Rejeite qualquer entrega cujo `webhook-timestamp` esteja fora de uma tolerância razoável (5 minutos é um bom padrão) em relação ao relógio do seu servidor. Isso impede que alguém capture uma entrega antiga, válida na época, e a reenvie mais tarde.
## Regenerar o segredo [#regenerar-o-segredo]
Regenerar o segredo de um endpoint **invalida o anterior imediatamente** — não há período de convivência entre os dois segredos. Depois de regenerar, atualize a verificação do lado da sua integração antes da próxima entrega, ou todas as assinaturas passam a falhar até você sincronizar o novo segredo.
## Links de mídia expiram em 7 dias [#links-de-mídia-expiram-em-7-dias]
Mensagens com mídia (imagem, áudio, documento...) trazem `media.url` apontando para um link assinado da Vensy, que redireciona para a URL pré-assinada do storage. Esse link funciona por até **7 dias** a partir do evento — o mesmo prazo de retenção do corpo da entrega. Baixe o arquivo e guarde-o no seu lado se precisar dele depois disso.