Receber eventos
A Luna entrega ao seu servidor tudo que chega nos seus números e toda mudança de estado das mensagens que você enviou. Cada entrega é um POST com corpo JSON e uma assinatura que prova a origem. Este guia mostra como receber, verificar e tratar.
Cadastre o endpoint
Pelo console, use Integração, Webhooks. Pela API, POST /v1/webhooks/endpoints com o escopo webhooks:write:
curl -X POST "https://api.lunahia.com.br/v1/webhooks/endpoints" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/luna"
}'const res = await fetch('https://api.lunahia.com.br/v1/webhooks/endpoints', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://example.com/webhooks/luna"
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/webhooks/endpoints",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"url": "https://example.com/webhooks/luna",
},
timeout=10,
)
print(res.status_code, res.json())201{
"id": "5c0e9a52-3a0e-4c3f-9f3e-0b3f6f1a2c77",
"url": "https://example.com/webhooks/luna",
"createdAt": "2026-10-06T14:00:00.000Z",
"updatedAt": "2026-10-06T14:00:00.000Z",
"previousSecretExpiresAt": null,
"secret": "whsec_EXAMPLE_NOT_A_REAL_SECRET"
}- O
secretaparece nesta resposta e na da rotação, e em mais nenhuma. A Luna guarda só a forma cifrada dele. Se você o perder, rotacione para receber um novo. - A URL precisa usar
https, sem usuário e senha embutidos, e precisa apontar para um endereço público na internet. - Endereço de rede interna, de laço local e de serviço de metadados de nuvem é recusado no cadastro e a cada entrega, porque um nome pode mudar de endereço entre o cadastro e a chamada.
- A Luna não segue redirecionamento. Cadastre a URL final.
GET /v1/webhooks/endpointslista o que já existe (escopowebhooks:read). Quem ainda não cadastrou nenhum recebe200com lista vazia, e não404.
curl -X GET "https://api.lunahia.com.br/v1/webhooks/endpoints" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/webhooks/endpoints', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.get(
"https://api.lunahia.com.br/v1/webhooks/endpoints",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())Como a Luna entrega
- Cada entrega é um
POSTna sua URL, comContent-Type: application/jsone o cabeçalhoX-Luna-Signature. - Qualquer resposta
2xxconta como entregue. A Luna espera até 10 segundos pela resposta. - Qualquer outra coisa conta como falha:
4xx,5xx, redirecionamento3xx, tempo esgotado e erro de conexão. - O corpo da sua resposta é descartado. Um corpo acima de 64 KB conta como falha, então responda vazio.
- Responda primeiro e trabalhe depois. Se o seu tratamento é lento, grave o evento numa fila sua e devolva
2xxna hora.
As garantias de entrega
Elas estão escritas sem eufemismo, porque quem paga uma duplicata é o consumidor final do seu produto:
- A entrega é at-least-once: a mesma entrega pode chegar mais de uma vez, e isso acontece de verdade, não apenas em teoria. Um tempo limite atingido depois de o seu servidor já ter processado a requisição produz exatamente esse caso.
- A ordem é garantida dentro de uma conversa. Ela não é garantida entre conversas: dois eventos de conversas diferentes podem chegar em qualquer ordem, e um evento de uma conversa lenta pode chegar depois de um evento mais recente de outra.
- A idempotência é dever seu, e o event_id é o que a plataforma dá para você cumpri-la. Ele é estável: a mesma entrega, repetida, traz o mesmo event_id. Guarde os que já processou e descarte os repetidos.
- Se o seu endpoint ficar fora do ar por muito tempo, a plataforma descarta o que ficou acumulado, e você perde esses eventos: eles não serão reenviados. Quando isso acontecer, você recebe um evento backlog.dropped dizendo quantos foram descartados e de que período. Esse aviso não é descartado junto com o acumulado que ele descreve.
- Novos tipos de evento podem aparecer sem que a versão do contrato mude. Se o seu servidor receber um event_type que não conhece, responda 2xx e ignore o evento. A versão só muda quando a forma de um tipo que já existe muda, e isso vem com aviso e prazo.
- Num número conectado em coexistência, se a importação das conversas anteriores ainda estiver em curso quando o prazo dela se aproxima, você recebe um evento history_sync.deadline_approaching com o número e o prazo como instante. Esse prazo é a estimativa da plataforma: 24 horas contadas de quando ela viu a importação começar. O relógio da Meta começa um pouco antes, então trate o instante como limite e aja com folga. Passado o prazo sem concluir, a plataforma da Meta desconecta o negócio final e ele precisa refazer a conexão no aparelho. O aviso sai antes do prazo e nunca depois, e é um só para o número: como toda entrega, ele pode se repetir, sempre com o mesmo event_id, e um número que refaz a conexão depois não recebe um segundo aviso. O estado corrente da importação está em GET /v1/numbers/{id}/health.
Os eventos
Todo evento tem o mesmo envelope, e o event_type diz qual é o tipo. Os exemplos abaixo são construídos do contrato publicado:
| Campo | O que é |
|---|---|
version | A versão do contrato, uma data (hoje 2026-08-25). Ela só muda quando a forma de um tipo que já existe muda, e isso vem com aviso e prazo. Um tipo novo de evento não a faz subir. |
event_id | O identificador estável do evento, 64 caracteres hexadecimais. A mesma entrega, repetida, traz o mesmo event_id. Use-o para descartar repetições. |
event_type | message.received, message.status, backlog.dropped ou history_sync.deadline_approaching. |
occurred_at | Quando o evento ocorreu, em ISO 8601, UTC, com milissegundos e o Z no fim. |
phone_number_id | O identificador do número na Meta, o mesmo metaPhoneNumberId que GET /v1/numbers devolve. |
`message.received`
Uma mensagem que chegou de um interlocutor.
{
"version": "2026-08-25",
"event_id": "fd2bd2cde42c3a4c6d2a2e9b94460191845cb35855bfbc2f2bcee21553ec8a21",
"event_type": "message.received",
"occurred_at": "2026-10-06T14:02:11.482Z",
"phone_number_id": "123456789012345",
"message": {
"wamid": "wamid.EXAMPLE_NOT_A_REAL_ID",
"wa_id": "5511999990000",
"type": "text",
"text": "Oi! Queria saber o status do meu pedido."
}
}| Campo de `message` | O que é |
|---|---|
wamid | O identificador da mensagem na Meta. |
wa_id | O identificador do interlocutor na Meta, em E.164 sem o sinal de mais. |
type | O tipo da mensagem, no vocabulário da Meta. |
text | O texto, quando o tipo o tem. Nulo nos demais tipos. |
O evento não carrega o arquivo de uma mídia. Para baixá-la, ache a mensagem em GET /v1/messages pelo wamid e peça GET /v1/messages/{id}/media. Veja Enviar mensagens.
`message.status`
Uma mudança de estado de uma mensagem que você enviou. Cada estado é um evento, com o seu próprio event_id.
{
"version": "2026-08-25",
"event_id": "464e089a80690f1792db6249678ddeced5ad5be0d46d95e2daa263779f7d92e5",
"event_type": "message.status",
"occurred_at": "2026-10-06T14:02:14.090Z",
"phone_number_id": "123456789012345",
"message": {
"wamid": "wamid.EXAMPLE_NOT_A_REAL_ID",
"wa_id": "5511999990000",
"status": "sent",
"error_code": null
}
}{
"version": "2026-08-25",
"event_id": "6a79ec64bfea6acc67135d032067fd6261aa568c3aea35af7999790a7730cf69",
"event_type": "message.status",
"occurred_at": "2026-10-06T14:02:15.311Z",
"phone_number_id": "123456789012345",
"message": {
"wamid": "wamid.EXAMPLE_NOT_A_REAL_ID",
"wa_id": "5511999990000",
"status": "failed",
"error_code": 131026
}
}| Campo de `message` | O que é |
|---|---|
wamid | O identificador da mensagem na Meta. |
wa_id | O identificador do interlocutor na Meta. |
status | O estado, no vocabulário da Meta: sent, delivered, read, played ou failed. Novos estados podem aparecer, e quem não conhece um valor deve ignorar o evento. |
error_code | O código de erro da Meta quando o estado é de falha. Nulo nos demais. |
- A Meta não emite
deliveredquando a entrega e a leitura coincidem.sentseguido direto dereadé uma sequência válida, e não um evento perdido. - O
wamidliga o evento à mensagem que você enviou:GET /v1/messagesdevolve owamidde cada mensagem junto doidda Luna. - Para o motivo de uma falha, em português e com a ação recomendada, leia
GET /v1/messages/{id}. Veja Erros.
`backlog.dropped`
A plataforma descartou eventos do acumulado do seu endpoint e não os reenviará. É o aviso, e ele não é descartado junto com o que descreve.
{
"version": "2026-08-25",
"event_id": "f76937420c2e818b87ce9b6acc5cb192c059ed2e33cda23339d5cf650b762ce5",
"event_type": "backlog.dropped",
"occurred_at": "2026-10-06T14:30:00.000Z",
"phone_number_id": "123456789012345",
"backlog": {
"motivo": "idade",
"quantidade": 37,
"janela_inicio": "2026-10-06T09:00:00.000Z",
"janela_fim": "2026-10-06T14:30:00.000Z"
}
}| Campo de `backlog` | O que é |
|---|---|
motivo | idade, quando as mensagens ficaram velhas demais para serem acionáveis. volume, quando o acumulado do endpoint passou do teto da plataforma. |
quantidade | Quantos eventos foram descartados nesta ocorrência. |
janela_inicio | O evento mais antigo descartado. |
janela_fim | O evento mais recente descartado. |
Quando isso acontece, reconcilie pelo histórico: GET /v1/messages traz o que entrou e saiu, do mais recente ao mais antigo.
`history_sync.deadline_approaching`
O aviso da importação do histórico de um número em coexistência. O objeto traz o prazo e mais nada. Veja Conectar um número.
{
"version": "2026-08-25",
"event_id": "d42ca56e44ef023d52b0a0739dcf3afb0d5054a8b84321c41e432ed6707b043f",
"event_type": "history_sync.deadline_approaching",
"occurred_at": "2026-10-07T03:00:00.000Z",
"phone_number_id": "123456789012345",
"history_sync": {
"prazo": "2026-10-07T09:00:00.000Z"
}
}Verifique a assinatura
Qualquer pessoa pode mandar um POST para a sua URL. A assinatura é o que prova que a entrega veio da Luna. Verifique antes de tratar o evento. Cada entrega traz um cabeçalho:
X-Luna-Signature: t=1760000000,v1=3f1c0a9e5b7d2c4a6e8f0b1d3c5a7e9f2b4d6c8a0e1f3a5b7c9d1e3f5a7b9c1d- Leia o
t, o instante da assinatura em segundos desde 1970. Recuse a entrega se ele estiver a mais de 300 segundos do seu relógio, para qualquer lado. Isso impede que alguém reapresente uma requisição capturada. - Calcule o HMAC-SHA256 do texto
t+.+ corpo bruto, com o segredo do endpoint como chave, e escreva o resultado em hexadecimal. O segredo é a string inteira,whsec_incluído. - Compare o resultado com cada valor
v1=do cabeçalho, em tempo constante. Se algum for igual, a assinatura é válida.
- Use os bytes exatos que chegaram. Se você ler o JSON e serializar de novo, a ordem das chaves ou o escape pode mudar, e a assinatura falha de forma intermitente. Em Express, use
express.raw({ type: "application/json" })nesta rota. Em Flask,request.get_data(). Em FastAPI,await request.body(). - Compare em tempo constante (
timingSafeEqual,compare_digest), e nunca com==. - Durante a rotação do segredo o cabeçalho traz duas assinaturas,
v1=repetido. Você conhece uma delas, e pode ser a segunda. Por isso o código confere todas. - Responda
401a uma assinatura inválida. A Luna conta só2xxcomo entregue, então um401nunca é tomado por sucesso.
A função de verificação
Salve como verify.mjs (Node) ou verify.py (Python):
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
// rawBody: the exact bytes received (a Buffer), never re-serialized JSON.
export function verifyLunaSignature(rawBody, header, secret, nowMs = Date.now()) {
if (typeof header !== 'string' || !secret) return false;
const m = /^t=(\d{1,15}),/.exec(header);
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(Math.floor(nowMs / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest();
// During a secret rotation the header carries two v1 values: check them all.
let ok = false;
for (const [, hex] of header.matchAll(/v1=([0-9a-fA-F]{64})/g)) {
if (crypto.timingSafeEqual(Buffer.from(hex, 'hex'), expected)) ok = true;
}
return ok;
}import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
def verify_luna_signature(raw_body, header, secret, now=None):
# raw_body: the exact bytes received, never re-serialized JSON.
if not header or not secret:
return False
m = re.match(r"^t=(\d{1,15}),", header)
if not m:
return False
t = int(m.group(1))
now = time.time() if now is None else now
if abs(int(now) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# During a secret rotation the header carries two v1 values: check them all.
ok = False
for sig in re.findall(r"v1=([0-9a-fA-F]{64})", header):
if hmac.compare_digest(sig.lower(), expected):
ok = True
return okUm servidor completo
Salve como server.mjs ou server.py, ao lado do arquivo anterior, e rode com LUNA_WEBHOOK_SECRET definida:
import http from 'node:http';
import { verifyLunaSignature } from './verify.mjs';
const SECRET = process.env.LUNA_WEBHOOK_SECRET; // whsec_...
const seen = new Set(); // in production: a database or Redis, with an expiry
function handle(event) {
switch (event.event_type) {
case 'message.received':
console.log('received', event.message.wa_id, event.message.text);
break;
case 'message.status':
console.log('status', event.message.wamid, event.message.status);
break;
default:
console.log('ignored', event.event_type); // new types can appear
}
}
http
.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks);
if (!verifyLunaSignature(raw, req.headers['x-luna-signature'], SECRET)) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end(); // answer first, work after
const event = JSON.parse(raw.toString('utf8'));
if (seen.has(event.event_id)) return; // at-least-once: drop repeats
seen.add(event.event_id);
handle(event);
});
})
.listen(Number(process.env.PORT ?? 3000));import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from verify import verify_luna_signature
SECRET = os.environ["LUNA_WEBHOOK_SECRET"] # whsec_...
seen = set() # in production: a database or Redis, with an expiry
def handle(event):
kind = event["event_type"]
if kind == "message.received":
print("received", event["message"]["wa_id"], event["message"]["text"])
elif kind == "message.status":
print("status", event["message"]["wamid"], event["message"]["status"])
else:
print("ignored", kind) # new types can appear
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
header = self.headers.get("X-Luna-Signature")
if not verify_luna_signature(raw, header, SECRET):
self.send_response(401)
self.send_header("Content-Length", "0")
self.end_headers()
return
self.send_response(200) # answer first, work after
self.send_header("Content-Length", "0")
self.end_headers()
event = json.loads(raw)
if event["event_id"] in seen: # at-least-once: drop repeats
return
seen.add(event["event_id"])
handle(event)
HTTPServer(("", int(os.environ.get("PORT", "3000"))), Handler).serve_forever()Set em memória do exemplo some quando o processo reinicia. Em produção, guarde os event_id já processados num banco ou num Redis, com prazo de expiração, e descarte os repetidos.Retentativas, rejeitados e reenvio
Quando a entrega falha
- A Luna tenta de novo com espera crescente. O teto de cada espera é de 30 segundos, 90 segundos, 4,5 minutos, 13,5 minutos e 40,5 minutos, e depois 1 hora entre as tentativas seguintes. A espera de cada tentativa é sorteada entre a metade e o valor cheio.
- Cinco falhas seguidas abrem um disjuntor: a Luna para de bater no endpoint e deixa passar uma sonda por minuto. Três sucessos seguidos o fecham. Quando o endpoint volta, o acumulado é entregue em ritmo crescente, para não derrubá-lo de novo.
- Quando a política de retentativa se esgota, a Luna desiste e o evento vai para a fila de rejeitados.
- Separado disso, o acumulado de um endpoint tem dois tetos: 10.000 eventos pendentes e seis horas de idade. O mais antigo é descartado ao passar de qualquer um, e você recebe
backlog.dropped. - Esses valores são técnicos e idênticos para todos os clientes.
A fila de rejeitados
Um evento chega aqui depois de a política de retentativa se esgotar. Ele não é entregue de novo sozinho: ou você o re-enfileira, ou ele fica lá até a retenção expirar. Escopo webhooks:read para listar e webhooks:write para re-enfileirar.
curl -X GET "https://api.lunahia.com.br/v1/webhooks/dlq?limit=50" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/webhooks/dlq?limit=50', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.get(
"https://api.lunahia.com.br/v1/webhooks/dlq?limit=50",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())200{
"items": [
{
"id": "0b6f6f2a-6d3e-4b8e-9a52-2f3c1d4e5a60",
"endpointId": "5c0e9a52-3a0e-4c3f-9f3e-0b3f6f1a2c77",
"eventType": "message.received",
"wamid": "wamid.EXAMPLE_NOT_A_REAL_ID",
"rejectedAt": "2026-10-06T16:00:00.000Z",
"failureReason": "http_5xx",
"requeuedAt": null
}
],
"nextCursor": null
}failureReasoné a classe da última falha:http_4xx(o seu servidor entendeu e recusou),http_5xx(aceitou e quebrou),timeout,conexaooudestino_recusado.- A resposta não traz total, de propósito. Para saber quantos itens há, pagine até
nextCursorvir nulo. Olimitvai até 100. - O corpo do evento não vem na lista. Re-enfileire para recebê-lo no seu endpoint, assinado como qualquer outra entrega.
curl -X POST "https://api.lunahia.com.br/v1/webhooks/dlq/YOUR_DLQ_ITEM_ID/reenfileirar" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/webhooks/dlq/YOUR_DLQ_ITEM_ID/reenfileirar', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/webhooks/dlq/YOUR_DLQ_ITEM_ID/reenfileirar",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())A resposta é 202: o pedido foi registrado, e a entrega acontece de forma assíncrona. Repetir a chamada para o mesmo item responde 409 com error: "already_requeued" e o instante do primeiro pedido, e não republica de novo. Se o seu endpoint ainda estiver fora do ar, a entrega será adiada e retentada pela política de sempre. Não adianta pedir em massa para acelerar.
Reenviar o evento de uma mensagem
Para testar o seu endpoint, ou para recuperar o evento de uma mensagem que você já tem, peça o reenvio. O escopo é webhooks:write, e não messages:*: o escopo acompanha o efeito, e o efeito é causar entrega no seu endpoint.
curl -X POST "https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/replay" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/replay', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/replay",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())202{
"deliveryId": "9f2a1b0c-3d4e-4f5a-8b9c-0d1e2f3a4b5c",
"requestedAt": "2026-10-06T18:30:00.000Z"
}- Cada pedido produz uma entrega nova, com
deliveryIdpróprio. Chamar duas vezes produz duas entregas, e não um erro. - O reenvio não fura a fila nem pula defesa nenhuma.
- Sem endpoint cadastrado a resposta é
409comerror: "no_webhook_endpoint", e não um202que não se cumpre.
Rotacione o segredo
Rotacione quando o segredo vazar, ou quando você o perder. A rotação não derruba a sua integração:
curl -X POST "https://api.lunahia.com.br/v1/webhooks/endpoints/YOUR_ENDPOINT_ID/rotacionar" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/webhooks/endpoints/YOUR_ENDPOINT_ID/rotacionar', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/webhooks/endpoints/YOUR_ENDPOINT_ID/rotacionar",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())- A resposta traz o segredo novo, uma única vez.
- O segredo anterior continua produzindo assinatura válida por sete dias. Nesse período cada entrega carrega as duas assinaturas no mesmo cabeçalho.
previousSecretExpiresAtdiz quando o anterior deixa de valer. Enquanto ele estiver no futuro, o endpoint está no meio de uma rotação.- Rotacionar de novo antes do fim da janela descarta o mais antigo dos dois: nunca existem três segredos válidos.