Primeiros passos
Este guia leva você de uma conta nova a uma mensagem enviada e a um evento recebido. Os passos do console estão descritos em palavras, e os da API trazem exemplos em curl, Node e Python.
Antes de começar
- Um e-mail e uma senha de pelo menos 12 caracteres para a conta.
- Um número de WhatsApp com código de país do Brasil (+55). A Luna atende só esses números.
- Uma conta de WhatsApp Business do dono do número, e um método de pagamento dele na Meta. O número e a conta são do negócio final, e a Meta cobra dele o uso da plataforma WhatsApp.
- Um endereço HTTPS público para receber webhooks. Em desenvolvimento, um túnel HTTPS para a sua máquina serve.
- Node 20 ou mais novo, ou Python 3 com
requests, para rodar os exemplos. O curl também serve.
Todos os exemplos usam valores de mentira que começam com YOUR_. Troque cada um pelo seu. A chave de API vem da variável de ambiente LUNA_API_KEY.
1. Crie a conta
- Abra o cadastro no console.
- Preencha o nome da empresa, o e-mail e a senha (duas vezes), e toque em Criar conta.
- Abra o e-mail que chega e clique no link de confirmação. Ele vale por sete dias e só funciona uma vez.
- Entre no console com o mesmo e-mail e a mesma senha. A sessão dura oito horas.
2. Conecte um número
- No menu Números, abra Conectar número e toque em Conectar WhatsApp.
- A janela da Meta abre. O dono do número escolhe a conta de WhatsApp e o número, e autoriza o acesso. Não feche a aba até a janela terminar.
- Quando a Luna terminar o registro, o número aparece em Números. Pela API, ele fica com
stateigual aREGISTERED.
O número já está no aplicativo WhatsApp Business do celular, ou vem de outro provedor? Os dois casos têm passos próprios em Conectar um número.
3. Crie uma chave de API
- No menu Integração, abra Chaves de API e toque em Criar chave.
- Confirme a sua senha. O console pede de novo, mesmo com a sessão aberta, e não volta a pedir pelas próximas duas horas.
- Dê um rótulo e marque só os escopos deste guia:
numbers:read,webhooks:write,messages:writeemessages:read. - Copie o segredo, que começa com
hlsn_. Ele aparece uma única vez. Não há rota que o devolva depois: se você o perder, revogue a chave e crie outra.
Guarde a chave numa variável de ambiente, e nunca no código:
export LUNA_API_KEY="hlsn_YOUR_KEY_HERE"Agora leia os seus números. A resposta traz o metaPhoneNumberId, que é o valor que o envio pede em from:
curl -X GET "https://api.lunahia.com.br/v1/numbers" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/numbers', {
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/numbers",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json()){
"data": [
{
"id": "ed404df1-b420-4ed6-8743-ba1609892e0a",
"metaPhoneNumberId": "123456789012345",
"displayNumber": "+55 11 99999-0000",
"state": "REGISTERED",
"orientacao": "Número conectado e operando. Mensagens já entram e saem por ele."
}
]
}O campo id é o identificador do número na Luna. O metaPhoneNumberId é o da Meta. As rotas de envio pedem o da Meta, e as de mídia e de detalhe pedem o da Luna.
4. Cadastre o webhook
Primeiro suba um servidor que recebe o evento. Os dois arquivos abaixo verificam a assinatura de cada entrega e imprimem o que chega. Salve o primeiro como verify.mjs (ou verify.py) e o segundo como server.mjs (ou server.py), e rode com LUNA_WEBHOOK_SECRET definida depois do cadastro.
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 okimport 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()Agora cadastre a URL pública do servidor. Pelo console, use Integração, Webhooks, Cadastrar endpoint. Pela API:
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"
}secret aparece nesta resposta e em mais nenhuma. Guarde-o agora, em LUNA_WEBHOOK_SECRET. A URL precisa ser https e apontar para um endereço público, e a Luna não segue redirecionamento.5. Envie a primeira mensagem
Texto livre só sai para quem falou com o seu número nas últimas 24 horas. Então, antes de enviar, escreva do seu celular para o número conectado. Isso abre a janela, e é também o seu primeiro evento recebido (veja o passo 6).
Depois responda por POST /v1/messages. O to é o telefone do destinatário, só dígitos, com código do país e sem o sinal de mais:
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"text": {
"body": "Olá! Recebemos seu pedido e já estamos preparando."
}
}'const res = await fetch('https://api.lunahia.com.br/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"text": {
"body": "Olá! Recebemos seu pedido e já estamos preparando."
}
}),
});
console.log(res.status, await res.json());import os
import uuid
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"text": {
"body": "Olá! Recebemos seu pedido e já estamos preparando.",
},
},
timeout=10,
)
print(res.status_code, res.json())202{
"id": "ae60f288-7565-4315-a440-3663e9c231e1",
"status": "queued"
}O 202 quer dizer que a Luna aceitou a mensagem e a pôs na fila de saída. O id é o identificador dela na Luna. O identificador da Meta (wamid) e o estado de entrega chegam depois, pelo webhook. Para consultar agora:
curl -X GET "https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID', {
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/messages/YOUR_MESSAGE_ID",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())6. Veja o primeiro evento
O servidor do passo 4 deve ter impresso o message.received da mensagem que você escreveu do celular, e depois um message.status para cada mudança de estado da resposta que você enviou. Estes são os corpos:
message.received{
"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."
}
}message.status{
"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
}
}Se nada chegou
- Leia o número com
GET /v1/numbers/{id}e olhesubscriptionConfirmedAt. Se ele for nulo, a inscrição do aplicativo na conta ainda não foi confirmada, e nenhum evento chega. - Confira se o servidor responde
2xxem até 10 segundos. Qualquer outra resposta conta como falha e a entrega é retentada. - Peça de novo o evento de uma mensagem com
POST /v1/messages/{id}/replay. Detalhes em Receber eventos. - O estado do envio e o motivo de uma falha estão em
GET /v1/messages/{id}, no campoerros. Veja Erros.
Próximos passos
- Conecte um número que continua no aplicativo do celular, ou que vem de outro provedor: Conectar um número.
- Conheça os quatro eventos e as garantias de entrega: Receber eventos.
- Mande imagem, documento, botões e mais: Enviar mensagens.
- Inicie conversa fora da janela de 24 horas: Templates.
- O contrato de cada rota está na referência da API.