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 (webhook-dispatcher.ts:14-18).
| 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. |
{
"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
},
"connection": {
"id": "<uuid>",
"type": "whatsapp",
"name": "Comercial"
}
}
}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. |
| 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"
}