LunaDocumentaçãoEntrar

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

  1. Abra o cadastro no console.
  2. Preencha o nome da empresa, o e-mail e a senha (duas vezes), e toque em Criar conta.
  3. Abra o e-mail que chega e clique no link de confirmação. Ele vale por sete dias e só funciona uma vez.
  4. Entre no console com o mesmo e-mail e a mesma senha. A sessão dura oito horas.
Confirmar o e-mail é o que libera a conexão de número. Antes disso a conta já pode criar chaves e integrar contra a API, mas não consegue abrir a conexão.

2. Conecte um número

  1. No menu Números, abra Conectar número e toque em Conectar WhatsApp.
  2. 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.
  3. Quando a Luna terminar o registro, o número aparece em Números. Pela API, ele fica com state igual a REGISTERED.

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

  1. No menu Integração, abra Chaves de API e toque em Criar chave.
  2. 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.
  3. Dê um rótulo e marque só os escopos deste guia: numbers:read, webhooks:write, messages:write e messages:read.
  4. 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())
Resposta (resumida)
{
  "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 ok
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()

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())
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 campo 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())
Resposta 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 olhe subscriptionConfirmedAt. 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 2xx em 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 campo erros. Veja Erros.

Próximos passos