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).

EventoQuando dispara
message.receivedUma mensagem inbound (do contato) chega numa conexão da empresa.
message.sentUma mensagem outbound (da empresa ou da IA) é enviada a um contato.
message.status_updatedO status de entrega de uma mensagem muda (ex.: enviado → entregue → lido).
contact.createdUm novo contato é criado na plataforma.
Corpo real de um evento message.received (webhook-dispatcher.ts:76-82, 44-57, 22-36, 38-42)
{
  "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)).

Validação em Node.js
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 limitsecret_required (padrão)public
Por slug60 req/min30 req/min
Por IP10 req/min5 req/min
HTTPcodeQuando ocorre
401INVALID_SECRETHeader X-Prosalab-Secret ausente ou incorreto (modo secret_required).
404ENDPOINT_NOT_FOUNDNenhum endpoint cadastrado com esse slug.
410ENDPOINT_INACTIVEEndpoint existe mas está desativado.
403FEATURE_DISABLEDA conta não tem o inbound habilitado (ver callout acima).
400 / 413INVALID_PAYLOADJSON inválido ou corpo maior que 100KB.
400MISSING_PHONEphone_number não encontrado no payload via o variable_mapping configurado.
400MAPPING_FAILEDErro ao aplicar o variable_mapping sobre o payload recebido.
429RATE_LIMITEDLimite de requisições por slug ou por IP excedido.
500INTERNAL_ERRORErro interno (banco de dados ou inesperado).
Exemplo de payload recebido (mapeado por variable_mapping)
{
  "contact": {
    "firstname": "João",
    "phone": "5548999990000"
  }
}
Resposta de sucesso (route.ts:236-242)
{
  "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).

HTTPcodeQuando ocorre
404TEMPLATE_NOT_FOUNDToken inválido (não é UUID) ou nenhum template com esse dispatch_token.
403FEATURE_DISABLEDA conta não tem o webhook v2 habilitado.
403TEMPLATE_NOT_APPROVEDO template não está com status APPROVED na Meta.
400 / 413INVALID_PAYLOADCorpo maior que 50KB, ou variável com tag de personalização não substituída (ex.: %FIRSTNAME% chegou literal).
400MISSING_PHONENenhum telefone no corpo (phone_number) nem na query (?phone=/?phone_number=).
400INVALID_PHONETelefone não normaliza pra um número BR válido.
429RATE_LIMITEDLimite de requisições por token ou por IP excedido.
502SEND_FAILEDErro do provedor ao enviar o template (inclui também INSERT_FAILED/PHANTOM_INSERT internos do dispatch).
500INTERNAL_ERRORErro interno inesperado.
Rate limitValor
Por token60 req/min (tier padrão — o endpoint não usa modo público)
Por IP10 req/min
Formato 1 — corpo JSON
{
  "phone_number": "5548999990000",
  "variables": {
    "1": "João",
    "2": "Consulta de avaliação"
  }
}
Formato 2 — query-string (ex.: ActiveCampaign)
POST /api/webhooks/template-dispatch/3f2a9c1e-...-b7d4?phone=5548999990000&v1=Jo%C3%A3o&v2=Consulta+de+avalia%C3%A7%C3%A3o HTTP/1.1
Resposta de sucesso (route.ts:198-204)
{
  "ok": true,
  "wamid": "wamid.HBgL...",
  "contact_id": "<uuid>",
  "message_id": "<uuid>",
  "action": "sent"
}
Webhooks — Prosalab Developers