Webhooks
Webhooks completam a integração com a Prosalab: a plataforma AVISA seus sistemas quando algo acontece (outbound) e RECEBE dados de sistemas externos pra criar/atualizar contatos ou disparar templates (inbound).
Visão geral
Existem dois fluxos independentes: outbound (a Prosalab envia eventos pra uma URL sua) e inbound (sistemas como n8n ou ActiveCampaign enviam dados pra uma URL da Prosalab).
Os webhooks outbound são configurados na plataforma, em Configurações → API/Webhooks, e a disponibilidade depende do plano contratado. Não existe endpoint da API pública pra criar, listar ou apagar webhooks outbound via API — o cadastro é feito só pela interface (src/app/api/settings/webhooks/route.ts).
Eventos outbound
A Prosalab dispara um POST assinado pra cada URL cadastrada, para cada evento ao qual ela está inscrita (src/lib/webhooks/event-types.ts). Quem assina * recebe todos os eventos, inclusive os que forem criados depois.
Os campos de estado de IA de message.sent, message.received e message.status_updated vêm do banco, não de um valor padrão. Se a leitura do contato falhar, o evento sai com is_ai_enabled e external_ai_enabled iguais a true.
Em contact.opted_out, phone traz o telefone do contato ou, quando ele não tem telefone gravado, o identificador do canal. connection_id é a conexão por onde chegou o pedido ou, sem ela, a última conexão usada pelo contato, e pode vir null. Uma falha no envio deste aviso nunca desfaz o bloqueio: ele continua valendo na Prosalab.
| Evento | Quando dispara |
|---|---|
message.received | Uma mensagem inbound (do contato) chega numa conexão da empresa. |
message.sent | Uma mensagem outbound (da empresa ou da IA) é enviada a um contato. |
message.status_updated | O status de entrega de uma mensagem muda (ex.: enviado → entregue → lido). |
contact.created | Um novo contato é criado na plataforma. |
contact.opted_out | O contato passa a bloquear mensagens (opt-out). Sai uma única vez por bloqueio, no momento em que ele é gravado; um contato que já estava bloqueado e volta a pedir não gera outro aviso. |
| Valor de `data.source` em `contact.opted_out` | Origem do bloqueio |
|---|---|
meta_cloud_inbound | O contato pediu para sair (texto como "parar" ou "sair", ou o botão "Bloquear mensagens") numa conexão da API oficial do WhatsApp. |
uazapi_inbound | O contato pediu para sair numa conexão WhatsApp via QR code, na mensagem recebida ou no histórico da conversa verificado pela Prosalab. |
campaign_reply | O pedido para sair veio como resposta a uma campanha. |
backfill | Reenvio retroativo feito pela equipe da Prosalab para contatos que já estavam bloqueados antes deste evento existir. opted_out_at traz a data original do bloqueio, não a do reenvio. |
| Campo de `data.contact` | O que traz |
|---|---|
is_ai_enabled | Estado gravado da IA da Prosalab para o contato no momento do evento. |
external_ai_enabled | Estado gravado da IA externa (sua automação) para o contato. Passa a false, entre outros motivos, quando o operador pausa a IA pelo inbox, responde pelo inbox (mensagem ou template) ou responde pelo celular (eco do WhatsApp com menos de 10 minutos e mais de 5 segundos depois da última mensagem do contato, para não contar a resposta automática do app). Também passa a false em opt-out, handoff e quando o operador escolhe outro modo de IA no menu. Não volta sozinho quando a pausa vence: passa a true de novo quando alguém religa a IA externa pelo menu de IA do inbox. |
ai_paused_until | Horário até o qual a IA da Prosalab fica pausada, ou null. |
opted_out_at | Quando o contato pediu para não receber mensagens, ou null. |
{
"event": "message.received",
"timestamp": "2026-07-15T09:20:00.000Z",
"company_id": "<uuid>",
"delivery_id": "<uuid>",
"data": {
"message_id": "<uuid>",
"type": "text",
"content": "Oi, gostaria de saber o valor da consulta",
"media_url": null,
"media_mimetype": null,
"media_caption": null,
"direction": "inbound",
"sender_type": "contact",
"status": "received",
"created_at": "2026-07-15T09:20:00.000Z",
"contact": {
"id": "<uuid>",
"phone": "5548999990000",
"name": "João Silva",
"source_platform": "whatsapp",
"is_ai_enabled": true,
"external_ai_enabled": true,
"ai_paused_until": null,
"opted_out_at": null
},
"connection": {
"id": "<uuid>",
"type": "whatsapp",
"name": "Comercial"
}
}
}{
"event": "contact.opted_out",
"timestamp": "2026-09-24T14:05:00.000Z",
"company_id": "<uuid>",
"delivery_id": "<uuid>",
"data": {
"contact_id": "<uuid>",
"phone": "5548999990000",
"name": "João Silva",
"source_platform": "whatsapp",
"connection_id": "<uuid>",
"opted_out_at": "2026-09-24T14:05:00.000Z",
"source": "meta_cloud_inbound"
}
}Assinatura HMAC
Todo POST outbound carrega três headers de verificação (webhook-dispatcher.ts:167-173): X-Prosalab-Signature no formato sha256=<hex>, X-Prosalab-Event com o tipo do evento, e X-Prosalab-Delivery com um UUID único da entrega (útil pra deduplicar reentregas).
A assinatura é o HMAC-SHA256 do corpo BRUTO (a string exata enviada, sem re-serializar) usando o secret do webhook, em hexadecimal (webhook-dispatcher.ts:86-88, 159, 169). Re-serializar o JSON antes de validar pode reordenar chaves e quebrar a comparação — sempre valide sobre o texto bruto recebido, nunca sobre JSON.stringify(JSON.parse(rawBody)).
import { createHmac, timingSafeEqual } from 'crypto';
function isValidSignature(rawBody: string, signatureHeader: string, secret: string): boolean {
// rawBody = o corpo EXATO recebido (string bruta, antes de qualquer parse/re-stringify)
const expectedHex = createHmac('sha256', secret).update(rawBody).digest('hex');
const expected = `sha256=${expectedHex}`;
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
if (a.length !== b.length) return false; // timingSafeEqual exige mesmo tamanho
return timingSafeEqual(a, b);
}
// Uso num handler Express/Next:
// const rawBody = await request.text();
// const signature = request.headers.get('x-prosalab-signature') ?? '';
// const secret = process.env.MEU_WEBHOOK_SECRET!; // o secret cadastrado nesse webhook
// if (!isValidSignature(rawBody, signature, secret)) {
// return new Response('Assinatura inválida', { status: 401 });
// }Retentativas
Quando a entrega falha (timeout, erro de rede ou resposta HTTP fora da faixa 2xx), a Prosalab reagenda automaticamente com backoff crescente, até 5 tentativas no total (webhook-dispatcher.ts:238-244): 30 segundos → 2 minutos → 10 minutos → 30 minutos (4 retentativas após a entrega inicial). Depois da última tentativa sem sucesso, a entrega é marcada como failed.
Responda 2xx o mais rápido possível e processe o payload de forma assíncrona (fila, job em background). Se seu endpoint demorar demais ou ficar indisponível, a Prosalab vai reenviar o mesmo evento — seu processamento downstream deve ser idempotente usando o delivery_id.
Inbound de contatos
Sistemas externos (n8n, ActiveCampaign) podem enviar dados de contato pra Prosalab via POST /api/webhooks/incoming/{slug}, onde {slug} identifica um endpoint específico cadastrado na sua conta. A Prosalab aplica o variable_mapping configurado (chaves canônicas → JSONPath no payload recebido) e faz upsert do contato por telefone (route.ts:1-22, 138-146; webhook-inbound.types.ts:23-35).
Chaves canônicas reconhecidas no mapeamento: first_name (alias name) e phone_number (alias phone, obrigatório pra localizar/criar o contato). Exemplo de variable_mapping: { "first_name": "$.contact.firstname", "phone_number": "$.contact.phone" } (webhook-inbound.types.ts:30).
O inbound de contatos é um recurso sob HABILITAÇÃO PRÉVIA por conta (flag webhook_v2_enabled em company_business_rules, resolvida em src/lib/feature-flags/webhook-v2.ts:34-67, fail-closed em qualquer erro). Sem a habilitação, toda chamada retorna 403 FEATURE_DISABLED — fale com o suporte da Prosalab pra ativar antes de integrar.
Por padrão o endpoint exige o header X-Prosalab-Secret (comparação timing-safe contra o secret do endpoint — route.ts:130-136, 249-255). Endpoints podem ser configurados em MODO PÚBLICO (auth_mode: "public"), que dispensa esse header pra facilitar integração com ferramentas que não suportam headers customizados — o trade-off é que qualquer pessoa com a URL consegue enviar dados; por isso o modo público aplica limites de taxa mais baixos (ver Rate limits abaixo). Prefira sempre o modo com secret quando a ferramenta de origem suportar.
| Rate limit | secret_required (padrão) | public |
|---|---|---|
| Por slug | 60 req/min | 30 req/min |
| Por IP | 10 req/min | 5 req/min |
| HTTP | code | Quando ocorre |
|---|---|---|
| 401 | INVALID_SECRET | Header X-Prosalab-Secret ausente ou incorreto (modo secret_required). |
| 404 | ENDPOINT_NOT_FOUND | Nenhum endpoint cadastrado com esse slug. |
| 410 | ENDPOINT_INACTIVE | Endpoint existe mas está desativado. |
| 403 | FEATURE_DISABLED | A conta não tem o inbound habilitado (ver callout acima). |
| 400 / 413 | INVALID_PAYLOAD | JSON inválido ou corpo maior que 100KB. |
| 400 | MISSING_PHONE | phone_number não encontrado no payload via o variable_mapping configurado. |
| 400 | MAPPING_FAILED | Erro ao aplicar o variable_mapping sobre o payload recebido. |
| 429 | RATE_LIMITED | Limite de requisições por slug ou por IP excedido. |
| 500 | INTERNAL_ERROR | Erro interno (banco de dados ou inesperado). |
{
"contact": {
"firstname": "João",
"phone": "5548999990000"
}
}{
"ok": true,
"contact_id": "<uuid>",
"action": "created",
"custom_fields_captured": 0,
"new_pending_definitions": []
}Disparo de template
Sistemas externos disparam um template Meta-aprovado pra um número específico via POST /api/webhooks/template-dispatch/{token}, onde {token} é o UUID v4 único do template (o próprio token funciona como segredo — não é exigido header adicional) (template-dispatch/route.ts:1-28).
Os dados podem vir de duas formas: corpo JSON (recomendado, ex.: n8n) ou query-string (pensado pra ferramentas como ActiveCampaign, que postam form-data e usam tags de personalização na URL). Quando o corpo é um JSON válido, ele tem precedência sobre a query-string (route.ts:97-134).
O rate limit do disparo de template usa o mesmo mecanismo do inbound (checkRateLimit), mas sem publicMode — sempre no tier padrão de 60 req/min por token e 10 req/min por IP (template-dispatch/route.ts:85-95; incoming-rate-limit.ts:19-23).
| HTTP | code | Quando ocorre |
|---|---|---|
| 404 | TEMPLATE_NOT_FOUND | Token inválido (não é UUID) ou nenhum template com esse dispatch_token. |
| 403 | FEATURE_DISABLED | A conta não tem o webhook v2 habilitado. |
| 403 | TEMPLATE_NOT_APPROVED | O template não está com status APPROVED na Meta. |
| 403 | CONTACT_OPTED_OUT | O contato pediu para não receber mensagens (contacts.opted_out_at preenchido). |
| 403 | CONTACT_BLOCKED | O contato está bloqueado (contacts.is_blocked). |
| 409 | DUPLICATE_SEND | Mesmo template ao mesmo contato dentro da janela (MARKETING: 24h por nome; UTILITY/AUTH: conteúdo idêntico em 10 min). O corpo traz existing com message_id, sent_at e next_eligible_at — NÃO re-tente antes de next_eligible_at. existing ausente quando a recusa vem de dois POSTs simultâneos (envio anterior ainda em voo). |
| 503 | CONTACT_STATE_UNAVAILABLE | Não foi possível ler o estado do contato (indisponibilidade transitória). Não é um estado do contato — re-tente. |
| 400 / 413 | INVALID_PAYLOAD | Corpo maior que 50KB, ou variável com tag de personalização não substituída (ex.: %FIRSTNAME% chegou literal). |
| 400 | MISSING_PHONE | Nenhum telefone no corpo (phone_number) nem na query (?phone=/?phone_number=). |
| 400 | INVALID_PHONE | Telefone não normaliza pra um número BR válido. |
| 429 | RATE_LIMITED | Limite de requisições por token ou por IP excedido. |
| 502 | SEND_FAILED | Erro do provedor ao enviar o template (inclui também INSERT_FAILED/PHANTOM_INSERT internos do dispatch). |
| 500 | INTERNAL_ERROR | Erro interno inesperado. |
| Rate limit | Valor |
|---|---|
| Por token | 60 req/min (tier padrão — o endpoint não usa modo público) |
| Por IP | 10 req/min |
{
"phone_number": "5548999990000",
"variables": {
"1": "João",
"2": "Consulta de avaliação"
}
}POST /api/webhooks/template-dispatch/3f2a9c1e-...-b7d4?phone=5548999990000&v1=Jo%C3%A3o&v2=Consulta+de+avalia%C3%A7%C3%A3o HTTP/1.1{
"ok": true,
"wamid": "wamid.HBgL...",
"contact_id": "<uuid>",
"message_id": "<uuid>",
"action": "sent"
}