LunaDocsSign in

Receive events

Luna delivers to your server everything that arrives on your numbers and every state change of the messages you sent. Each delivery is a POST with a JSON body and a signature that proves the origin. This guide shows how to receive, verify and handle it.

Register the endpoint

In the console, use Integração (Integration), Webhooks. Through the API, POST /v1/webhooks/endpoints with the webhooks:write scope:

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 appears in this response and in the rotation’s, and in no other. Luna keeps only its encrypted form. If you lose it, rotate to get a new one.
  • The URL must use https, with no embedded user and password, and must point to a public address on the internet.
  • Internal-network, loopback and cloud-metadata addresses are refused at registration and at each delivery, because a name can change address between registration and the call.
  • Luna does not follow redirects. Register the final URL.
  • GET /v1/webhooks/endpoints lists what exists (webhooks:read scope). Whoever has not registered one yet gets 200 with an empty list, not 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())

How Luna delivers

  • Each delivery is a POST to your URL, with Content-Type: application/json and the X-Luna-Signature header.
  • Any 2xx answer counts as delivered. Luna waits up to 10 seconds for the answer.
  • Anything else counts as a failure: 4xx, 5xx, a 3xx redirect, a timeout and a connection error.
  • The body of your answer is discarded. A body above 64 KB counts as a failure, so answer empty.
  • Answer first and work afterwards. If your handling is slow, write the event to a queue of your own and return 2xx right away.

The delivery guarantees

They are written without euphemism, because whoever pays for a duplicate is your product’s end consumer:

  • Delivery is at-least-once: the same delivery can arrive more than once, and this happens in practice, not just in theory. A timeout reached after your server has already processed the request produces exactly that case.
  • Order is guaranteed within a conversation. It is not guaranteed across conversations: two events from different conversations can arrive in any order, and an event from a slow conversation can arrive after a newer event from another one.
  • Idempotency is your duty, and the event_id is what the platform gives you to fulfil it. It is stable: the same delivery, repeated, carries the same event_id. Keep the ones you have processed and drop the repeats.
  • If your endpoint stays down for too long, the platform drops what piled up, and you lose those events: they will not be resent. When that happens you receive a backlog.dropped event stating how many were dropped and for what period. That notice is not dropped along with the backlog it describes.
  • New event types can appear without the contract version changing. If your server receives an event_type it does not know, answer 2xx and ignore the event. The version only changes when the shape of an existing type changes, and that comes with notice and a deadline.
  • On a number connected in coexistence, if the import of earlier conversations is still in progress when its deadline approaches, you receive a history_sync.deadline_approaching event carrying the number and the deadline as an instant. That deadline is the platform's estimate: 24 hours from when it saw the import begin. Meta's own clock starts slightly earlier, so treat the instant as a limit and act with margin. Once the deadline passes without completion, the Meta platform disconnects the end business and it has to redo the connection on the device. The notice is sent before the deadline and never after, and there is a single one for the number: like any delivery, it can repeat, always with the same event_id, and a number that redoes the connection later does not receive a second notice. The current state of the import is at GET /v1/numbers/{id}/health.

The events

Every event has the same envelope, and event_type says which type it is. The examples below are built from the published contract:

FieldWhat it is
versionThe contract version, a date (today 2026-08-25). It only changes when the shape of an existing type changes, and that comes with notice and a deadline. A new event type does not make it rise.
event_idThe event’s stable identifier, 64 hexadecimal characters. The same delivery, repeated, carries the same event_id. Use it to drop repeats.
event_typemessage.received, message.status, backlog.dropped or history_sync.deadline_approaching.
occurred_atWhen the event occurred, in ISO 8601, UTC, with milliseconds and the trailing Z.
phone_number_idThe number’s identifier at Meta, the same metaPhoneNumberId that GET /v1/numbers returns.

`message.received`

A message that arrived from a correspondent.

{
  "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` fieldWhat it is
wamidThe message’s identifier at Meta.
wa_idThe correspondent’s identifier at Meta, in E.164 without the plus sign.
typeThe message type, in Meta’s vocabulary.
textThe text, when the type has one. Null on the other types.

The event does not carry a media file. To download it, find the message in GET /v1/messages by its wamid and call GET /v1/messages/{id}/media. See Send messages.

`message.status`

A state change of a message you sent. Each state is its own event, with its own event_id.

Sent
{
  "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
  }
}
Failure
{
  "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
  }
}
`message` fieldWhat it is
wamidThe message’s identifier at Meta.
wa_idThe correspondent’s identifier at Meta.
statusThe state, in Meta’s vocabulary: sent, delivered, read, played or failed. New states can appear, and whoever does not know a value should ignore the event.
error_codeMeta’s error code when the state is a failure. Null on the others.
  • Meta does not emit delivered when delivery and reading coincide. sent followed straight by read is a valid sequence, not a lost event.
  • The wamid ties the event to the message you sent: GET /v1/messages returns each message’s wamid next to Luna’s id.
  • For the reason of a failure, in Portuguese and with the recommended action, read GET /v1/messages/{id}. See Errors.

`backlog.dropped`

The platform dropped events from your endpoint’s backlog and will not resend them. This is the notice, and it is not dropped along with what it describes.

{
  "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"
  }
}
`backlog` fieldWhat it is
motivoidade (age), when the messages got too old to act on. volume, when the endpoint’s backlog went over the platform’s ceiling.
quantidadeHow many events were dropped in this occurrence.
janela_inicioThe oldest event dropped.
janela_fimThe newest event dropped.

When this happens, reconcile from the history: GET /v1/messages returns what came in and went out, newest first.

`history_sync.deadline_approaching`

The history import notice of a number in coexistence. The object carries the deadline and nothing else. See Connect a number.

{
  "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"
  }
}

Verify the signature

Anyone can send a POST to your URL. The signature is what proves the delivery came from Luna. Verify before handling the event. Each delivery carries a header:

X-Luna-Signature: t=1760000000,v1=3f1c0a9e5b7d2c4a6e8f0b1d3c5a7e9f2b4d6c8a0e1f3a5b7c9d1e3f5a7b9c1d
  1. Read t, the signing instant in seconds since 1970. Refuse the delivery if it is more than 300 seconds from your clock, in either direction. That stops someone replaying a captured request.
  2. Compute the HMAC-SHA256 of the text t + . + raw body, with the endpoint’s secret as the key, and write the result in hexadecimal. The secret is the whole string, whsec_ included.
  3. Compare the result with each v1= value in the header, in constant time. If any is equal, the signature is valid.
  • Use the exact bytes that arrived. If you parse the JSON and serialize it again, the key order or escaping can change, and the signature fails intermittently. In Express, use express.raw({ type: "application/json" }) on this route. In Flask, request.get_data(). In FastAPI, await request.body().
  • Compare in constant time (timingSafeEqual, compare_digest), never with ==.
  • During a secret rotation the header carries two signatures, v1= repeated. You know one of them, and it can be the second. That is why the code checks them all.
  • Answer 401 to an invalid signature. Luna counts only 2xx as delivered, so a 401 is never taken for success.

The verification function

Save as verify.mjs (Node) or 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

A complete server

Save as server.mjs or server.py, next to the previous file, and run with LUNA_WEBHOOK_SECRET set:

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()
The in-memory Set in the example disappears when the process restarts. In production, keep the processed event_ids in a database or Redis, with an expiry, and drop the repeats.

Retries, rejected events and replay

When delivery fails

  • Luna tries again with a growing wait. Each wait’s ceiling is 30 seconds, 90 seconds, 4.5 minutes, 13.5 minutes and 40.5 minutes, and then 1 hour between the following attempts. Each attempt’s wait is drawn between half and the full value.
  • Five failures in a row open a circuit breaker: Luna stops hitting the endpoint and lets one probe through per minute. Three successes in a row close it. When the endpoint is back, the backlog is delivered at a growing pace, so as not to knock it over again.
  • When the retry policy is exhausted, Luna gives up and the event goes to the rejected queue.
  • Separately, an endpoint’s backlog has two ceilings: 10,000 pending events and six hours of age. The oldest is dropped when either is passed, and you receive backlog.dropped.
  • These values are technical and identical for every customer.

The rejected queue

An event gets here after the retry policy is exhausted. It is not delivered again on its own: either you requeue it, or it stays there until retention expires. webhooks:read scope to list and webhooks:write to requeue.

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())
200 response
{
  "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 is the class of the last failure: http_4xx (your server understood and refused), http_5xx (accepted and broke), timeout, conexao (connection) or destino_recusado (destination refused).
  • The response carries no total, on purpose. To know how many items there are, page until nextCursor comes back null. limit goes up to 100.
  • The event body does not come in the list. Requeue it to receive it at your endpoint, signed like any other delivery.
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())

The response is 202: the request was recorded, and delivery happens asynchronously. Repeating the call for the same item answers 409 with error: "already_requeued" and the instant of the first request, and does not republish again. If your endpoint is still down, delivery will be postponed and retried by the usual policy. Requesting in bulk does not speed it up.

Replay a message’s event

To test your endpoint, or to recover the event of a message you already have, ask for a replay. The scope is webhooks:write, not messages:*: the scope follows the effect, and the effect is causing a delivery at your 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())
202 response
{
  "deliveryId": "9f2a1b0c-3d4e-4f5a-8b9c-0d1e2f3a4b5c",
  "requestedAt": "2026-10-06T18:30:00.000Z"
}
  • Each request produces a new delivery, with its own deliveryId. Calling twice produces two deliveries, not an error.
  • The replay does not skip the queue or any defense.
  • With no endpoint registered the answer is 409 with error: "no_webhook_endpoint", not a 202 that does not come true.

Rotate the secret

Rotate when the secret leaks, or when you lose it. Rotation does not take your integration down:

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())
  • The response carries the new secret, exactly once.
  • The previous secret keeps producing a valid signature for seven days. During that period each delivery carries both signatures in the same header.
  • previousSecretExpiresAt says when the previous one stops being valid. While it is in the future, the endpoint is in the middle of a rotation.
  • Rotating again before the window ends drops the older of the two: there are never three valid secrets.
Each route’s full contract is in the API reference, in the Webhooks section.