LunaDocsSign in

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

  1. Open the sign-up page in the console.
  2. Fill in the company name, the e-mail and the password (twice), and click Criar conta (Create account).
  3. Open the e-mail you receive and click the confirmation link. It is valid for seven days and works only once.
  4. Sign in to the console with the same e-mail and password. The session lasts eight hours.
Confirming the e-mail is what unlocks connecting a number. Before that, the account can already create keys and integrate against the API, but it cannot open the connection.

2. Connect a number

  1. In the Números (Numbers) menu, open Conectar número (Connect number) and click Conectar WhatsApp (Connect WhatsApp).
  2. 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.
  3. When Luna finishes registering, the number shows up in Números. Through the API, its state is REGISTERED.

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

  1. In the Integração (Integration) menu, open Chaves de API (API keys) and click Criar chave (Create key).
  2. Confirm your password. The console asks again even with the session open, and does not ask again for the next two hours.
  3. Give it a label and tick only this guide’s scopes: numbers:read, webhooks:write, messages:write and messages:read.
  4. 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())
Response (trimmed)
{
  "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 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()

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"
}
The 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 at subscriptionConfirmedAt. 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 2xx within 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 the erros field. 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.