Quickstart
This guide takes you from a new account to a sent message and a received event. The console steps are described in words, and the API steps come with examples in curl, Node and Python.
Before you start
- An e-mail and a password of at least 12 characters for the account.
- A WhatsApp number with the Brazil country code (+55). Luna serves only those numbers.
- A WhatsApp Business account owned by the number’s owner, and a payment method of theirs at Meta. The number and the account belong to the end business, and Meta charges them for WhatsApp platform usage.
- A public HTTPS address to receive webhooks. In development, an HTTPS tunnel to your machine works.
- Node 20 or newer, or Python 3 with
requests, to run the examples. curl works too.
Every example uses made-up values that start with YOUR_. Replace each one with yours. The API key comes from the LUNA_API_KEY environment variable.
1. Create the account
- Open the sign-up page in the console.
- Fill in the company name, the e-mail and the password (twice), and click Criar conta (Create account).
- Open the e-mail you receive and click the confirmation link. It is valid for seven days and works only once.
- Sign in to the console with the same e-mail and password. The session lasts eight hours.
2. Connect a number
- In the Números (Numbers) menu, open Conectar número (Connect number) and click Conectar WhatsApp (Connect WhatsApp).
- Meta’s window opens. The number’s owner chooses the WhatsApp account and the number, and authorizes access. Do not close the tab until the window finishes.
- When Luna finishes registering, the number shows up in Números. Through the API, its
stateisREGISTERED.
Is the number already on the WhatsApp Business app on the phone, or does it come from another provider? Both cases have their own steps in Connect a number.
3. Create an API key
- In the Integração (Integration) menu, open Chaves de API (API keys) and click Criar chave (Create key).
- Confirm your password. The console asks again even with the session open, and does not ask again for the next two hours.
- Give it a label and tick only this guide’s scopes:
numbers:read,webhooks:write,messages:writeandmessages:read. - Copy the secret, which starts with
hlsn_. It appears exactly once. No route returns it afterwards: if you lose it, revoke the key and create another.
Keep the key in an environment variable, never in code:
export LUNA_API_KEY="hlsn_YOUR_KEY_HERE"Now read your numbers. The response carries the metaPhoneNumberId, which is the value the send asks for in 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."
}
]
}The id field is the number’s identifier at Luna. The metaPhoneNumberId is Meta’s. The send routes ask for Meta’s, and the media and detail routes ask for Luna’s.
4. Register the webhook
First bring up a server that receives the event. The two files below verify the signature of every delivery and print what arrives. Save the first as verify.mjs (or verify.py) and the second as server.mjs (or server.py), and run it with LUNA_WEBHOOK_SECRET set after registering.
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()Now register the server’s public URL. In the console, use Integração, Webhooks, Cadastrar endpoint (Register endpoint). Through the 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 response{
"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 field appears in this response and in no other. Store it now, in LUNA_WEBHOOK_SECRET. The URL must be https and point to a public address, and Luna does not follow redirects.5. Send the first message
Free-form text only goes out to someone who messaged your number in the last 24 hours. So before sending, write from your phone to the connected number. That opens the window, and it is also your first received event (see step 6).
Then reply with POST /v1/messages. The to is the recipient’s phone, digits only, with the country code and without the plus sign:
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 response{
"id": "ae60f288-7565-4315-a440-3663e9c231e1",
"status": "queued"
}The 202 means Luna accepted the message and put it on the outbound queue. The id is its identifier at Luna. Meta’s identifier (wamid) and the delivery state arrive later, over the webhook. To check now:
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. See the first event
The server from step 4 should have printed the message.received for the message you wrote from your phone, and then a message.status for each state change of the reply you sent. These are the bodies:
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
}
}If nothing arrived
- Read the number with
GET /v1/numbers/{id}and look atsubscriptionConfirmedAt. If it is null, the app’s subscription to the account has not been confirmed yet, and no event arrives. - Check that your server answers
2xxwithin 10 seconds. Any other answer counts as a failure and the delivery is retried. - Ask for a message’s event again with
POST /v1/messages/{id}/replay. Details in Receive events. - The send state and the reason for a failure are in
GET /v1/messages/{id}, in theerrosfield. See Errors.
Next steps
- Connect a number that stays on the phone app, or that comes from another provider: Connect a number.
- Learn the four events and the delivery guarantees: Receive events.
- Send images, documents, buttons and more: Send messages.
- Start a conversation outside the 24-hour window: Templates.
- Each route’s contract is in the API reference.