LunaDocumentaçãoEntrar

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())
Resposta 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 secret aparece 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/endpoints lista o que já existe (escopo webhooks:read). Quem ainda não cadastrou nenhum recebe 200 com lista vazia, e não 404.
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 POST na sua URL, com Content-Type: application/json e o cabeçalho X-Luna-Signature.
  • Qualquer resposta 2xx conta como entregue. A Luna espera até 10 segundos pela resposta.
  • Qualquer outra coisa conta como falha: 4xx, 5xx, redirecionamento 3xx, 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 2xx na 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:

CampoO que é
versionA 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_idO identificador estável do evento, 64 caracteres hexadecimais. A mesma entrega, repetida, traz o mesmo event_id. Use-o para descartar repetições.
event_typemessage.received, message.status, backlog.dropped ou history_sync.deadline_approaching.
occurred_atQuando o evento ocorreu, em ISO 8601, UTC, com milissegundos e o Z no fim.
phone_number_idO 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 é
wamidO identificador da mensagem na Meta.
wa_idO identificador do interlocutor na Meta, em E.164 sem o sinal de mais.
typeO tipo da mensagem, no vocabulário da Meta.
textO 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.

Enviada
{
  "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
  }
}
Falha
{
  "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 é
wamidO identificador da mensagem na Meta.
wa_idO identificador do interlocutor na Meta.
statusO 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_codeO código de erro da Meta quando o estado é de falha. Nulo nos demais.
  • A Meta não emite delivered quando a entrega e a leitura coincidem. sent seguido direto de read é uma sequência válida, e não um evento perdido.
  • O wamid liga o evento à mensagem que você enviou: GET /v1/messages devolve o wamid de cada mensagem junto do id da 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 é
motivoidade, quando as mensagens ficaram velhas demais para serem acionáveis. volume, quando o acumulado do endpoint passou do teto da plataforma.
quantidadeQuantos eventos foram descartados nesta ocorrência.
janela_inicioO evento mais antigo descartado.
janela_fimO 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
  1. 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.
  2. 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.
  3. 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 401 a uma assinatura inválida. A Luna conta só 2xx como entregue, então um 401 nunca é 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 ok

Um 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()
O 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())
Resposta 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, conexao ou destino_recusado.
  • A resposta não traz total, de propósito. Para saber quantos itens há, pagine até nextCursor vir nulo. O limit vai 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())
Resposta 202
{
  "deliveryId": "9f2a1b0c-3d4e-4f5a-8b9c-0d1e2f3a4b5c",
  "requestedAt": "2026-10-06T18:30:00.000Z"
}
  • Cada pedido produz uma entrega nova, com deliveryId pró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 é 409 com error: "no_webhook_endpoint", e não um 202 que 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.
  • previousSecretExpiresAt diz 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.
O contrato completo de cada rota está na referência da API, na seção Webhooks.