Skip to content

Avançado

Tópicos para quem está colocando o webhook em produção: idempotência, validação de origem, integrações de referência (WhatsApp via Meta, Slack), como lidar com URLs de arquivos que somem, troubleshooting e limitações atuais.


Idempotência

O Lumo entrega at-least-once (pelo menos uma vez). O mesmo payload pode chegar mais de uma vez em casos como:

  • Sua resposta 2xx chegou tarde (perto do timeout de 10s) e o Lumo já havia decidido tentar de novo.
  • Sua infraestrutura ficou indisponível por alguns minutos no meio de uma sequência de retries.
  • O processo de envio do Lumo foi reiniciado entre a entrega e o ack.

Para evitar processar o mesmo alerta duas vezes, identifique cada entrega por uma chave estável e ignore repetições.

Chave de idempotência recomendada

Combine alert.id + user.id + timestamp:

typescript
function chaveIdempotencia(payload: AlertWebhookPayload): string {
  return `${payload.alert.id}:${payload.user.id}:${payload.timestamp}`;
}

Padrão de implementação

typescript
// Tabela: processed_alerts (chave PK)
//   key TEXT PRIMARY KEY
//   processed_at TIMESTAMP DEFAULT NOW()

async function processarAlerta(payload: AlertWebhookPayload) {
  const key = chaveIdempotencia(payload);

  const inserido = await db.query(
    `INSERT INTO processed_alerts (key) VALUES ($1)
     ON CONFLICT (key) DO NOTHING
     RETURNING key`,
    [key],
  );

  if (inserido.rowCount === 0) {
    // Já processado antes — responda 200 mas pule a lógica.
    return;
  }

  await processarDeVerdade(payload);
}

TIP

Mesmo quando você detecta uma duplicata, responda 200. Retornar erro para um payload já processado faz o Lumo tentar de novo desnecessariamente. "Duplicata reconhecida" é sucesso do ponto de vista do envio.

TTL da chave

Mantenha as chaves de idempotência por pelo menos 24 horas (cobre o pior caso de retry com janelas longas + reinícios). Em sistemas de alto volume, considere TTL de 7 dias e armazenar em Redis com EXPIRE.


Validação de Origem

WARNING

O Lumo não envia assinatura criptográfica do payload. As duas formas suportadas de autenticar o request no seu endpoint são: caminho secreto na URL e headers HTTP personalizados (por exemplo Authorization: Bearer <token>). Os dois podem (e devem) ser combinados.

Caminho secreto na URL

Cadastre a URL do webhook do tenant com um token aleatório no path:

https://api.exemplo.com/webhook/lumo/7f3a9b2e8c4d6f5a1b9e0c8d7f6a5b4c

O token deve ter alta entropia (32+ caracteres aleatórios). Gere assim:

bash
# UUID v4
uuidgen | tr -d '-' | tr 'A-Z' 'a-z'

# ou bytes aleatórios em hex
openssl rand -hex 16

No seu receptor:

typescript
app.post("/webhook/lumo/:secret", (req, res) => {
  if (req.params.secret !== process.env.LUMO_WEBHOOK_SECRET) {
    return res.status(404).end();  // 404 (não 401) — não revela que o caminho existe
  }
  // ... processar
});

TIP

Responda 404 (não 401) para tokens inválidos. 401 confirma que existe um endpoint protegido naquela rota; 404 dá menos informação para quem está sondando.

Headers HTTP personalizados

Em paralelo (ou em vez) do caminho secreto, o Lumo permite enviar headers HTTP customizados em cada POST. É a forma mais comum de autenticar um webhook contra um endpoint genérico (ex.: middleware n8n, gateway próprio, Meta Cloud API).

Configure em HEC → Tenants → Editar Tenant → Canais de Alerta Permitidos → Webhook → Headers HTTP Customizados. Exemplo:

http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Tenant-Identifier: cliente-acme

No seu receptor:

typescript
app.post("/webhook/lumo", (req, res) => {
  const auth = req.header("authorization");
  if (auth !== `Bearer ${process.env.LUMO_WEBHOOK_TOKEN}`) {
    return res.status(404).end();  // 404, não 401 — não confirma rota protegida
  }
  // ... processar
});

Limites e regras detalhadas (limites de quantidade/tamanho, headers reservados) estão na referência do payload.

TIP

Para defesa em profundidade, combine caminho secreto + header de autenticação. Mesmo se o token de um for vazado (log indevido, captura de tela), o outro ainda protege o endpoint.

Boas práticas adicionais

  • Rotacione o token periodicamente. Mude no Lumo e no seu receptor ao mesmo tempo.
  • Não logue o token em arquivos de log nem em mensagens de erro.
  • Cadastre o token via variável de ambiente no seu serviço (LUMO_WEBHOOK_SECRET), nunca hardcoded.
  • Considere restringir o endpoint a um caminho /webhook/lumo/... específico, não a um /webhook/... genérico.

Por que não recomendamos hoje:

  • Filtrar por IP de origem: o Lumo roda em infraestrutura Kubernetes com IPs dinâmicos. Não publicamos lista de IPs fixos.
  • Validar User-Agent: o header Horus-Alert-Webhook/1.0 é trivial de falsificar — trate apenas como sinal informativo.

Integração WhatsApp via Meta Cloud API

INFO

Este é o caso de integração customizada mais frequente. O cliente quer mandar notificações via uma instância própria de WhatsApp Business (na conta dele com a Meta), em vez de usar o gateway nativo do Lumo. O caminho é: webhook do Lumo → seu middleware → Meta Cloud API.

Pré-requisitos

  1. Conta no Meta for Developers.
  2. Aplicação WhatsApp Business criada e um número de telefone aprovado pela Meta.
  3. Token de acesso permanente (System User Access Token recomendado para produção).
  4. phone_number_id do número aprovado.
  5. Modelos de mensagem (message templates) aprovados pela Meta — fora da janela de 24h de conversa, só dá pra mandar mensagem usando template.

Arquitetura

Implementação de referência (Node.js)

typescript
import express from "express";

const app = express();
app.use(express.json({ limit: "4mb" }));

const META_TOKEN = process.env.META_WHATSAPP_TOKEN!;
const META_PHONE_NUMBER_ID = process.env.META_PHONE_NUMBER_ID!;
const LUMO_SECRET = process.env.LUMO_WEBHOOK_SECRET!;

// Mapeamento de email/user.id no Lumo para telefone no WhatsApp.
// Em produção, isto vem do seu banco.
const TELEFONES: Record<string, string> = {
  "maria@empresa.com": "+5511999990000",
  "joao@empresa.com":  "+5511988880000",
};

app.post("/webhook/lumo/:secret", async (req, res) => {
  if (req.params.secret !== LUMO_SECRET) return res.status(404).end();

  // 1. Responde imediato — não bloqueie o Lumo.
  res.status(200).json({ received: true });

  // 2. Processa em background.
  try {
    await encaminharParaWhatsApp(req.body);
  } catch (err) {
    console.error("[whatsapp] falha", err);
  }
});

async function encaminharParaWhatsApp(payload: any) {
  const { alert, user, content } = payload;

  const telefone = TELEFONES[user.email];
  if (!telefone) {
    console.warn(`[whatsapp] sem telefone mapeado para ${user.email}`);
    return;
  }

  // Monta o texto consolidado a partir dos blocos.
  const linhas: string[] = [`*${alert.nome}*`, ""];
  const anexos: { url: string; tipo: "pdf" | "xlsx" | "png" }[] = [];

  for (const bloco of content) {
    if (bloco.type === "text" || bloco.type === "ai") {
      if (bloco.generated.text) linhas.push(bloco.generated.text, "");
    } else if (bloco.type === "report") {
      if (bloco.generated.pdf)  anexos.push({ url: bloco.generated.pdf,  tipo: "pdf" });
      if (bloco.generated.xlsx) anexos.push({ url: bloco.generated.xlsx, tipo: "xlsx" });
    } else if (bloco.type === "chart") {
      if ("chart" in bloco.generated) {
        anexos.push({ url: bloco.generated.chart, tipo: "png" });
      } else if (bloco.generated.kind === "dashboard" && bloco.generated.url) {
        anexos.push({ url: bloco.generated.url, tipo: "pdf" });
      }
    }
  }

  // 1. Envia mensagem de texto (via template aprovado pela Meta).
  await enviarTemplateMeta(telefone, "lumo_alerta_generico", [linhas.join("\n")]);

  // 2. Envia cada anexo como mídia. Para isso, primeiro baixa do Lumo
  //    (URLs do Lumo não são permanentes, baixe imediatamente) e
  //    faz upload na Meta para obter um media_id.
  for (const anexo of anexos) {
    const mediaId = await uploadParaMeta(anexo.url, anexo.tipo);
    await enviarMidiaMeta(telefone, mediaId, anexo.tipo);
  }
}

async function enviarTemplateMeta(
  para: string,
  templateName: string,
  parametros: string[],
) {
  const body = {
    messaging_product: "whatsapp",
    to: para.replace("+", ""),
    type: "template",
    template: {
      name: templateName,
      language: { code: "pt_BR" },
      components: [
        {
          type: "body",
          parameters: parametros.map((p) => ({ type: "text", text: p })),
        },
      ],
    },
  };

  const resp = await fetch(
    `https://graph.facebook.com/v20.0/${META_PHONE_NUMBER_ID}/messages`,
    {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${META_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    },
  );

  if (!resp.ok) {
    throw new Error(`Meta API falhou: ${resp.status} ${await resp.text()}`);
  }
}

async function uploadParaMeta(urlLumo: string, tipo: "pdf" | "xlsx" | "png"): Promise<string> {
  // 1. Baixa do Lumo (URLs não são permanentes — baixe agora).
  const arquivo = await fetch(urlLumo);
  if (!arquivo.ok) throw new Error(`download falhou: ${arquivo.status}`);
  const blob = await arquivo.blob();

  const mime = tipo === "pdf"  ? "application/pdf"
             : tipo === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
             :                   "image/png";

  // 2. Faz upload pra Meta usando multipart.
  const form = new FormData();
  form.append("messaging_product", "whatsapp");
  form.append("type", mime);
  form.append("file", blob, `arquivo.${tipo}`);

  const resp = await fetch(
    `https://graph.facebook.com/v20.0/${META_PHONE_NUMBER_ID}/media`,
    {
      method: "POST",
      headers: { "Authorization": `Bearer ${META_TOKEN}` },
      body: form,
    },
  );

  if (!resp.ok) throw new Error(`upload falhou: ${resp.status}`);
  const { id } = await resp.json() as { id: string };
  return id;
}

async function enviarMidiaMeta(para: string, mediaId: string, tipo: "pdf" | "xlsx" | "png") {
  const typeMeta = tipo === "png" ? "image" : "document";

  const body = {
    messaging_product: "whatsapp",
    to: para.replace("+", ""),
    type: typeMeta,
    [typeMeta]: { id: mediaId },
  };

  await fetch(
    `https://graph.facebook.com/v20.0/${META_PHONE_NUMBER_ID}/messages`,
    {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${META_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    },
  );
}

app.listen(3000);

Pontos de atenção

  • Templates da Meta precisam ser aprovados antes do uso fora da janela de 24h de conversa. Crie um template lumo_alerta_generico com um parâmetro de texto e aguarde a aprovação.
  • Janela de 24h: se o destinatário enviou alguma mensagem ao seu número nas últimas 24h, você pode mandar mensagem livre (sem template). Senão, só template aprovado.
  • Limite de envio da Meta: depende da qualidade do número e da escala (Tiers 250 → 1k → 10k → 100k → ilimitado/dia). Comece pequeno.
  • Custo por mensagem: a Meta cobra por categoria (utility, marketing, service) e por país. Confira o pricing atualizado.

TIP

Para casos mais simples, considere usar o canal WhatsApp nativo do Lumo em vez de integrar via Meta. O canal nativo já lida com instâncias, templates e fallbacks. A integração via webhook + Meta API é indicada apenas quando o cliente tem requisitos específicos da própria conta Meta.


Slack via Incoming Webhook

Bem mais simples que WhatsApp. O Slack expõe um Incoming Webhook por canal:

  1. No Slack: Apps → Incoming Webhooks → Add to Slack → escolha o canal. Copie a URL gerada (https://hooks.slack.com/services/...).
  2. No seu middleware, faça um POST com o payload no formato do Slack:
typescript
async function enviarSlack(webhookUrl: string, payload: any) {
  const { alert, user, content } = payload;

  const blocks: any[] = [
    {
      type: "header",
      text: { type: "plain_text", text: alert.nome },
    },
    {
      type: "context",
      elements: [
        { type: "mrkdwn", text: `Destinatário: *${user.email ?? user.id}*` },
      ],
    },
  ];

  for (const bloco of content) {
    if (bloco.type === "text" || bloco.type === "ai") {
      if (bloco.generated.text) {
        blocks.push({
          type: "section",
          text: { type: "mrkdwn", text: bloco.generated.text },
        });
      }
    } else if (bloco.type === "chart" && "chart" in bloco.generated) {
      blocks.push({
        type: "image",
        image_url: bloco.generated.chart,
        alt_text: bloco.nome,
      });
    } else if (bloco.type === "report") {
      const links: string[] = [];
      if (bloco.generated.pdf)  links.push(`<${bloco.generated.pdf}|PDF>`);
      if (bloco.generated.xlsx) links.push(`<${bloco.generated.xlsx}|Excel>`);
      if (links.length) {
        blocks.push({
          type: "section",
          text: { type: "mrkdwn", text: `*${bloco.nome}*: ${links.join(" • ")}` },
        });
      }
    }
  }

  await fetch(webhookUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ blocks }),
  });
}

WARNING

Os links de PDF/XLSX/PNG enviados ao Slack apontam direto para storage.horusbi.com.br. Se o storage for limpo, os links no Slack vão quebrar. Para preservar, baixe e re-hospede no seu próprio bucket antes de enviar ao Slack — ou suba os arquivos para o Slack via files.upload (com chat.postMessage referenciando o file_id).


Lidando com URLs que somem

As URLs em https://storage.horusbi.com.br/download/... não são permanentes. Há limpeza periódica do storage; arquivos podem deixar de existir após algumas semanas.

Regra de ouro

DANGER

Nunca persista uma URL do Lumo em banco de dados como se fosse permanente. Trate como link efêmero — válido só durante o processamento do webhook.

Estratégia recomendada

typescript
// Errado: salva URL crua no banco.
await db.insert({ alert_id: alert.id, pdf_url: bloco.generated.pdf });

// Certo: baixa, sobe no seu storage, salva URL própria.
const conteudo = await fetch(bloco.generated.pdf).then((r) => r.arrayBuffer());
const minhaUrl = await meuStorage.upload(conteudo, `alerta-${alert.id}.pdf`);
await db.insert({ alert_id: alert.id, pdf_url: minhaUrl });

O que fazer se receber 404 ao baixar

Pode acontecer se o webhook ficou em fila de retry por muito tempo e o storage foi limpo entre a geração e o seu processamento.

  • Trate como degradação aceitável — a entrega original do Lumo ainda foi um sucesso.
  • Logue a ocorrência (não como erro crítico).
  • Considere reduzir o atraso de processamento do seu lado.

Troubleshooting

SintomaCausa provávelAção
Webhook não chega ao seu endpointCanal "Webhook" não habilitado no alertaEdite o alerta, marque Webhook em Destinatários e Canais
Webhook não chega ao seu endpointURL do Webhook não cadastrada no tenantHEC → Tenants → Canais de Alerta Permitidos → URL do Webhook
Webhook não chega ao seu endpointURL com http:// em vez de https://Trocar para HTTPS; o Lumo rejeita HTTP antes de enviar
Webhook não chega ao seu endpointDNS do seu endpoint não resolve do datacenter do LumoVerifique se a URL é pública (sem VPN, sem IP privado)
Status no histórico fica "falha permanente"Seu endpoint demora mais de 10sResponda 200 imediato, processe em background
Status no histórico fica "falha permanente"Seu endpoint retorna status fora da faixa 2xxConfira o que o seu servidor está retornando; corrija para 2xx em caso de sucesso
Status no histórico fica "falha permanente"Erro de certificado TLSRenove o certificado; o Lumo valida a cadeia
Mesma entrega chega 2+ vezesSua resposta de sucesso chegou perto do timeoutImplemente idempotência (chave alert.id + user.id + timestamp)
Payload chega vazio (generated: {})A geração daquele bloco falhouVerifique no Lumo se o alerta gerou conteúdo no histórico; trate {} como caso válido
Link de PDF/XLSX/PNG retorna 404URL foi limpa do storageBaixe os arquivos imediatamente ao receber o webhook
Payload acima de 4 MBMuitos blocos ou anexos pesadosDivida o alerta em vários menores ou hospede arquivos pesados externamente
Header customizado não chega ao endpointNome do header tem caracteres inválidos ou foi marcado como reservadoCheque a configuração em HEC → Tenants → Webhook → Headers HTTP Customizados. Regras em Referência do Payload

Onde ver o que aconteceu

  • Histórico do alerta (Alertas → seu alerta → Histórico): mostra status de cada tentativa, status HTTP recebido, mensagem de erro e até 10 KB da resposta do seu endpoint.
  • Notificação interna: ao esgotar as 8 tentativas, o criador do alerta e os admins do tenant recebem uma notificação dentro do Lumo apontando o problema.

Limitações Conhecidas

Limitações atuais que você precisa considerar ao desenhar a integração.

LimitaçãoMitigação
Método HTTP fixo em POST (sem PUT/PATCH)Use middleware se o destino exige outro método
Sem URL por alerta (URL única por tenant)Use alert.id/alert.nome no roteamento do seu lado
Sem configuração de retry pelo usuário (8 tentativas e backoff são fixos)Use um proxy/fila se precisar de política diferente
URLs de arquivos não são permanentes (limpeza periódica do storage)Baixe ao receber, hospede no seu próprio storage

Não está no escopo:

  • Lista de IPs fixos de origem do Lumo. O serviço roda em Kubernetes com IPs dinâmicos; não publicamos nem prometemos lista estável.

Próximos Passos