Buscar K
Aparência
Aparência
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.
O Lumo entrega at-least-once (pelo menos uma vez). O mesmo payload pode chegar mais de uma vez em casos como:
Para evitar processar o mesmo alerta duas vezes, identifique cada entrega por uma chave estável e ignore repetições.
Combine alert.id + user.id + timestamp:
function chaveIdempotencia(payload: AlertWebhookPayload): string {
return `${payload.alert.id}:${payload.user.id}:${payload.timestamp}`;
}// 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.
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.
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.
Cadastre a URL do webhook do tenant com um token aleatório no path:
https://api.exemplo.com/webhook/lumo/7f3a9b2e8c4d6f5a1b9e0c8d7f6a5b4cO token deve ter alta entropia (32+ caracteres aleatórios). Gere assim:
# UUID v4
uuidgen | tr -d '-' | tr 'A-Z' 'a-z'
# ou bytes aleatórios em hex
openssl rand -hex 16No seu receptor:
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.
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:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Tenant-Identifier: cliente-acmeNo seu receptor:
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.
LUMO_WEBHOOK_SECRET), nunca hardcoded./webhook/lumo/... específico, não a um /webhook/... genérico.User-Agent: o header Horus-Alert-Webhook/1.0 é trivial de falsificar — trate apenas como sinal informativo.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.
phone_number_id do número aprovado.message templates) aprovados pela Meta — fora da janela de 24h de conversa, só dá pra mandar mensagem usando template.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);lumo_alerta_generico com um parâmetro de texto e aguarde a aprovação.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.
Bem mais simples que WhatsApp. O Slack expõe um Incoming Webhook por canal:
https://hooks.slack.com/services/...).POST com o payload no formato do Slack: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).
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.
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.
// 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 });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.
| Sintoma | Causa provável | Ação |
|---|---|---|
| Webhook não chega ao seu endpoint | Canal "Webhook" não habilitado no alerta | Edite o alerta, marque Webhook em Destinatários e Canais |
| Webhook não chega ao seu endpoint | URL do Webhook não cadastrada no tenant | HEC → Tenants → Canais de Alerta Permitidos → URL do Webhook |
| Webhook não chega ao seu endpoint | URL com http:// em vez de https:// | Trocar para HTTPS; o Lumo rejeita HTTP antes de enviar |
| Webhook não chega ao seu endpoint | DNS do seu endpoint não resolve do datacenter do Lumo | Verifique se a URL é pública (sem VPN, sem IP privado) |
| Status no histórico fica "falha permanente" | Seu endpoint demora mais de 10s | Responda 200 imediato, processe em background |
| Status no histórico fica "falha permanente" | Seu endpoint retorna status fora da faixa 2xx | Confira o que o seu servidor está retornando; corrija para 2xx em caso de sucesso |
| Status no histórico fica "falha permanente" | Erro de certificado TLS | Renove o certificado; o Lumo valida a cadeia |
| Mesma entrega chega 2+ vezes | Sua resposta de sucesso chegou perto do timeout | Implemente idempotência (chave alert.id + user.id + timestamp) |
Payload chega vazio (generated: {}) | A geração daquele bloco falhou | Verifique no Lumo se o alerta gerou conteúdo no histórico; trate {} como caso válido |
| Link de PDF/XLSX/PNG retorna 404 | URL foi limpa do storage | Baixe os arquivos imediatamente ao receber o webhook |
| Payload acima de 4 MB | Muitos blocos ou anexos pesados | Divida o alerta em vários menores ou hospede arquivos pesados externamente |
| Header customizado não chega ao endpoint | Nome do header tem caracteres inválidos ou foi marcado como reservado | Cheque a configuração em HEC → Tenants → Webhook → Headers HTTP Customizados. Regras em Referência do Payload |
Limitações atuais que você precisa considerar ao desenhar a integração.
| Limitação | Mitigaçã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: