Send messages
One route sends every message type: POST /v1/messages. The type field says which one, and a body without type is read as text. This guide covers the types, the 24-hour window rule, idempotency and how to follow delivery.
The basics
- The API key needs the
messages:writescope. fromis the number’s identifier at Meta, themetaPhoneNumberIdthatGET /v1/numbersreturns.tois the recipient’s phone in international format, digits only, without the plus sign. For example,5511999990000.- The response is
202with the message’sidat Luna andstatus: "queued". It exists before Meta answers.
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"
}Text goes up to 4096 characters, the same limit as the Cloud API. With preview_url: true in text, Meta fetches the first URL in the text to build a preview. It is off by default.
Why `202` and not `200`
The tightest limit on Meta’s platform is one message every six seconds to the same recipient. An agent that answers in bursts trips it on its own. Luna accepts, queues and delivers to Meta at the pace it allows, and that limit never comes back to you as a 429. The wamid and the delivery state arrive later, over the webhook.
Which identifier to use
| Where | Which identifier |
|---|---|
POST /v1/messages, from | Meta’s: metaPhoneNumberId. |
POST /v1/numbers/{id}/media | Luna’s: the id that GET /v1/numbers returns. |
GET /v1/messages, phoneNumberId | Luna’s: the number’s id. |
GET /v1/messages/{id} | The message’s id at Luna, the same one the 202 returned. |
reaction.message_id | The wamid of the message receiving the reaction. |
The 24-hour window
Free-form text only goes out to someone who wrote to your number in the last 24 hours. Outside that window, the only path is a template approved by Meta, and sending a template is what reopens the conversation. Template and reaction go through even with the window closed.
Luna applies the rule for you: outside the window, a free-form message is refused with 409 before any call to Meta, because a call refused over there is spent for nothing and counts as an error against your number.
409 response{
"error": "window_closed",
"message": "A janela de atendimento desta conversa está fechada. Só é possível enviar mensagem livre até 24 horas depois da última mensagem do contato.",
"fechadaEm": "2026-10-05T14:02:11.482Z",
"acaoRecomendada": "Envie um template aprovado para esta conversa. O template reabre a janela, e depois disso a mensagem livre é aceita."
}This response carries no Retry-After, on purpose: the window does not open with time. It opens when the contact writes, or when a template is delivered. Repeating the request only repeats the refusal.
Check the window before sending
GET /v1/conversations returns one row per correspondent, per number, from the most active to the most idle. messages:read scope.
curl -X GET "https://api.lunahia.com.br/v1/conversations?phoneNumberId=YOUR_NUMBER_ID" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/conversations?phoneNumberId=YOUR_NUMBER_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/conversations?phoneNumberId=YOUR_NUMBER_ID",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())200 response (trimmed){
"data": [
{
"id": "0f2c8e6a-1d3b-4a5c-9e7f-2b4d6a8c0e12",
"waId": "5511999990000",
"phoneNumberId": "7a1c9d3e-5f2b-4c8a-9d6e-1b3f5a7c9e11",
"displayNumber": "+55 11 99999-0000",
"lastMessage": {
"direction": "inbound",
"type": "text",
"bodyText": "Oi! Queria saber o status do meu pedido.",
"createdAt": "2026-10-06T14:02:11.482Z"
},
"windowOpen": true,
"windowExpiresAt": "2026-10-07T14:02:11.482Z",
"windowSource": "usuario"
}
],
"nextCursor": null
}windowOpen: truemeans you can send a free-form message to that correspondent now.falsemeans the path is a template.windowExpiresAtsays when the window closes, andwindowSourcesays what opened it:usuario(user),template,anuncio(ad) ordesconhecido(unknown). When there is no known window, the fields come back null or false, never absent.- The conversation
idis whatGET /v1/messages?conversationId=accepts. The filter’sphoneNumberIdis the number’s identifier at Luna. - A limitation worth knowing: grouping is by the
waIdthe platform recorded. If the identifier Meta recorded differs from the one typed at send time, the same person can show up in two conversations. - The response carries no count at all: no total, no counter per correspondent.
Idempotency
Send the Idempotency-Key header if you retry. With it, repeating the request does not produce a second send. The text example above already sends it, with a new UUID on each call.
- The header is optional. Whoever does not retry does not need it.
- The key is a value you choose for each logical message. One UUID for each message works. Reuse the same key only when retrying the same message.
- Luna recognizes the request by the key, not by the body. If you reuse a key with another body, the original message is the one that counts.
- The key’s scope is your account: the same key on two accounts is accepted on both.
- The guarantee also holds when the retry crosses the turn of the month.
The message types
All of them use POST /v1/messages with from, to and type. Below, only what changes in each type. All but reaction and template require the window to be open.
Text
type: "text" (or omitted), with text.body from 1 to 4096 characters. It is the example in the previous section.
Image
type: "image", with image.link or image.id, never both. caption up to 1024 characters. See media to choose between link and id.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "image",
"image": {
"link": "https://example.com/pedido.jpg",
"caption": "Seu pedido saiu para entrega."
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "image",
"image": {
"link": "https://example.com/pedido.jpg",
"caption": "Seu pedido saiu para entrega."
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "image",
"image": {
"link": "https://example.com/pedido.jpg",
"caption": "Seu pedido saiu para entrega.",
},
},
timeout=10,
)
print(res.status_code, res.json())Document
type: "document", with document.link or document.id. filename (up to 240 characters) is the name the file shows under for the recipient, and caption is up to 1024.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "document",
"document": {
"id": "YOUR_MEDIA_ID",
"filename": "nota-fiscal.pdf",
"caption": "Sua nota fiscal."
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "document",
"document": {
"id": "YOUR_MEDIA_ID",
"filename": "nota-fiscal.pdf",
"caption": "Sua nota fiscal."
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "document",
"document": {
"id": "YOUR_MEDIA_ID",
"filename": "nota-fiscal.pdf",
"caption": "Sua nota fiscal.",
},
},
timeout=10,
)
print(res.status_code, res.json())Video
type: "video", with video.link or video.id, and caption up to 1024 characters.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "video",
"video": {
"id": "YOUR_MEDIA_ID",
"caption": "Veja como montar."
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "video",
"video": {
"id": "YOUR_MEDIA_ID",
"caption": "Veja como montar."
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "video",
"video": {
"id": "YOUR_MEDIA_ID",
"caption": "Veja como montar.",
},
},
timeout=10,
)
print(res.status_code, res.json())Audio
type: "audio", with audio.link or audio.id. Audio has no caption.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "audio",
"audio": {
"id": "YOUR_MEDIA_ID"
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "audio",
"audio": {
"id": "YOUR_MEDIA_ID"
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "audio",
"audio": {
"id": "YOUR_MEDIA_ID",
},
},
timeout=10,
)
print(res.status_code, res.json())Sticker
type: "sticker", with sticker.link or sticker.id.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "sticker",
"sticker": {
"id": "YOUR_MEDIA_ID"
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "sticker",
"sticker": {
"id": "YOUR_MEDIA_ID"
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "sticker",
"sticker": {
"id": "YOUR_MEDIA_ID",
},
},
timeout=10,
)
print(res.status_code, res.json())Location
type: "location", with latitude (-90 to 90) and longitude (-180 to 180), and optional name and address, up to 1000 characters.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "location",
"location": {
"latitude": -19.9227,
"longitude": -43.9451,
"name": "Praça da Liberdade",
"address": "Belo Horizonte, MG"
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "location",
"location": {
"latitude": -19.9227,
"longitude": -43.9451,
"name": "Praça da Liberdade",
"address": "Belo Horizonte, MG"
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "location",
"location": {
"latitude": -19.9227,
"longitude": -43.9451,
"name": "Praça da Liberdade",
"address": "Belo Horizonte, MG",
},
},
timeout=10,
)
print(res.status_code, res.json())Contacts
type: "contacts", with a list of one to twenty cards. Each card requires name.formatted_name, and accepts phones, emails, addresses, urls, org and birthday (YYYY-MM-DD).
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "contacts",
"contacts": [
{
"name": {
"formatted_name": "Ana Souza",
"first_name": "Ana",
"last_name": "Souza"
},
"phones": [
{
"phone": "+55 11 99999-0001",
"type": "CELL"
}
]
}
]
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "contacts",
"contacts": [
{
"name": {
"formatted_name": "Ana Souza",
"first_name": "Ana",
"last_name": "Souza"
},
"phones": [
{
"phone": "+55 11 99999-0001",
"type": "CELL"
}
]
}
]
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "contacts",
"contacts": [
{
"name": {
"formatted_name": "Ana Souza",
"first_name": "Ana",
"last_name": "Souza",
},
"phones": [
{
"phone": "+55 11 99999-0001",
"type": "CELL",
},
],
},
],
},
timeout=10,
)
print(res.status_code, res.json())Reaction
type: "reaction", with the wamid of the message receiving the reaction in reaction.message_id and the emoji. Send an empty emoji to remove a reaction you had put. It goes through even with the window closed.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "reaction",
"reaction": {
"message_id": "wamid.EXAMPLE_NOT_A_REAL_ID",
"emoji": "👍"
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "reaction",
"reaction": {
"message_id": "wamid.EXAMPLE_NOT_A_REAL_ID",
"emoji": "👍"
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "reaction",
"reaction": {
"message_id": "wamid.EXAMPLE_NOT_A_REAL_ID",
"emoji": "👍",
},
},
timeout=10,
)
print(res.status_code, res.json())Interactive: reply buttons
type: "interactive" with interactive.type: "button". One to three buttons. Each button’s id (up to 256 characters) comes back in the webhook when it is tapped, and the title is up to 20 characters. The body (body.text) goes up to 1024, and the footer (footer.text) up to 60. The header (header) is optional, and can be text (up to 60 characters), image, video or document.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "button",
"body": {
"text": "Podemos confirmar a entrega para amanhã?"
},
"action": {
"buttons": [
{
"type": "reply",
"reply": {
"id": "confirma",
"title": "Confirmar"
}
},
{
"type": "reply",
"reply": {
"id": "remarca",
"title": "Remarcar"
}
}
]
}
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "button",
"body": {
"text": "Podemos confirmar a entrega para amanhã?"
},
"action": {
"buttons": [
{
"type": "reply",
"reply": {
"id": "confirma",
"title": "Confirmar"
}
},
{
"type": "reply",
"reply": {
"id": "remarca",
"title": "Remarcar"
}
}
]
}
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "button",
"body": {
"text": "Podemos confirmar a entrega para amanhã?",
},
"action": {
"buttons": [
{
"type": "reply",
"reply": {
"id": "confirma",
"title": "Confirmar",
},
},
{
"type": "reply",
"reply": {
"id": "remarca",
"title": "Remarcar",
},
},
],
},
},
},
timeout=10,
)
print(res.status_code, res.json())Interactive: list
interactive.type: "list". action.button is the label of the button that opens the list (up to 20 characters). One to ten sections, and one to ten rows per section. Each row has id (up to 200), title (up to 24) and an optional description (up to 72). The section title is up to 24 characters.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "list",
"body": {
"text": "Qual horário fica melhor?"
},
"action": {
"button": "Ver horários",
"sections": [
{
"title": "Manhã",
"rows": [
{
"id": "h09",
"title": "09:00"
},
{
"id": "h10",
"title": "10:00",
"description": "Primeiro horário livre"
}
]
}
]
}
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "list",
"body": {
"text": "Qual horário fica melhor?"
},
"action": {
"button": "Ver horários",
"sections": [
{
"title": "Manhã",
"rows": [
{
"id": "h09",
"title": "09:00"
},
{
"id": "h10",
"title": "10:00",
"description": "Primeiro horário livre"
}
]
}
]
}
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "list",
"body": {
"text": "Qual horário fica melhor?",
},
"action": {
"button": "Ver horários",
"sections": [
{
"title": "Manhã",
"rows": [
{
"id": "h09",
"title": "09:00",
},
{
"id": "h10",
"title": "10:00",
"description": "Primeiro horário livre",
},
],
},
],
},
},
},
timeout=10,
)
print(res.status_code, res.json())Interactive: link button
interactive.type: "cta_url". One button with display_text (up to 20 characters) and url (up to 2048), in action.parameters.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "cta_url",
"body": {
"text": "Acompanhe o seu pedido pelo link."
},
"action": {
"parameters": {
"display_text": "Acompanhar",
"url": "https://example.com/pedido/88213"
}
}
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "cta_url",
"body": {
"text": "Acompanhe o seu pedido pelo link."
},
"action": {
"parameters": {
"display_text": "Acompanhar",
"url": "https://example.com/pedido/88213"
}
}
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "interactive",
"interactive": {
"type": "cta_url",
"body": {
"text": "Acompanhe o seu pedido pelo link.",
},
"action": {
"parameters": {
"display_text": "Acompanhar",
"url": "https://example.com/pedido/88213",
},
},
},
},
timeout=10,
)
print(res.status_code, res.json())Template
type: "template", with template.name and template.language.code (for example pt_BR, which must match the approved language) and the components that fill the variables. It is what opens a conversation outside the window. See Templates.
curl -X POST "https://api.lunahia.com.br/v1/messages" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "confirmacao_de_pedido",
"language": {
"code": "pt_BR"
},
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana"
}
]
}
]
}
}'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',
},
body: JSON.stringify({
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "confirmacao_de_pedido",
"language": {
"code": "pt_BR"
},
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana"
}
]
}
]
}
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"type": "template",
"template": {
"name": "confirmacao_de_pedido",
"language": {
"code": "pt_BR",
},
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"parameter_name": "nome",
"text": "Ana",
},
],
},
],
},
},
timeout=10,
)
print(res.status_code, res.json())Media
Image, video, audio, document and sticker accept two ways of pointing at the file. Use one or the other, never both.
link: the file’s public URL, which Meta downloads at send time. It must answer without authentication.id: the identifier of a file you already uploaded. With it Meta does not need to download anything from your infrastructure, sending is faster, and you reuse the same file across messages. Meta keeps an uploaded file for 30 days. After that, upload again.
Upload a file
POST /v1/numbers/{id}/media, as multipart/form-data, in the file field. The id in the URL is the number’s at Luna. messages:write scope. The response carries the mediaId, not a URL: Meta’s download URL expires in five minutes, and a stored URL is born wrong.
curl -X POST "https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/media" \
-H "Authorization: Bearer $LUNA_API_KEY" \
-F "file=@./pedido.jpg;type=image/jpeg"import { readFile } from 'node:fs/promises';
const form = new FormData();
form.append(
'file',
new Blob([await readFile('./pedido.jpg')], { type: 'image/jpeg' }),
'pedido.jpg',
);
const res = await fetch('https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/media', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
},
body: form,
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://api.lunahia.com.br/v1/numbers/YOUR_NUMBER_ID/media",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
files={"file": ("pedido.jpg", open("pedido.jpg", "rb"), "image/jpeg")},
timeout=10,
)
print(res.status_code, res.json())200 response{
"mediaId": "1425835918205946"
}A file above the platform ceiling (100 MiB, the same for every customer) is refused with 413 before any call to Meta.
Download media the customer sent
GET /v1/messages/{id}/media returns a signed URL, issued by Luna, for the media it has already stored. messages:read scope.
curl -X GET "https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/media" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/media', {
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/media",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())200 response{
"url": "https://EXAMPLE.r2.cloudflarestorage.com/EXAMPLE_BUCKET/midia/EXAMPLE_KEY?X-Amz-Expires=300&X-Amz-Signature=EXAMPLE",
"expiraEm": "2026-10-06T14:07:11.482Z"
}- The URL expires in a few minutes and is a bearer credential: whoever holds it downloads the file, without presenting a key. Do not store it, do not log it, and do not send it over a channel you do not control. When it expires, call the route again.
- If the media has not been fetched yet, the response is
409witherror: "media_not_ready"and the currentestado, with no URL.pendente(pending) andfalhou(failed) come back on their own, because the sweep retries within Meta’s seven-day window.expirado(expired) is final. - The URL points to Luna’s storage, never to Meta, and does not carry a brand domain.
Follow delivery
The main path is the webhook: each state change arrives as message.status. See Receive events. To look a message up on the spot, or to reconcile, use GET /v1/messages/{id} (messages:read scope).
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())200 response{
"id": "ae60f288-7565-4315-a440-3663e9c231e1",
"status": "failed",
"criadaEm": "2026-10-06T14:02:11.000Z",
"enviadaEm": null,
"entregueEm": null,
"lidaEm": null,
"erros": [
{
"codigo": 131026,
"categoria": "nao_entregavel",
"mensagem": "O destinatário não pode receber esta mensagem: o número não tem WhatsApp, ou não existe.",
"acaoRecomendada": "Confira o número do destinatário e reenvie para o número correto. Repetir o envio para o mesmo número não muda o resultado.",
"original": {
"error": {
"message": "Message undeliverable",
"type": "OAuthException",
"code": 131026,
"fbtrace_id": "EXAMPLE_TRACE_ID"
}
}
}
]
}statusis the state as Meta last reported it, and it is null while no status event has arrived.enviadaEm(sent),entregueEm(delivered) andlidaEm(read) are null while the step has not happened.lidaEmalso stays null when the recipient turns read receipts off.erroslists what went wrong, in order of occurrence, and is empty when nothing did. See Errors.- The response carries no attempt count, cost or any usage number.
The history
GET /v1/messages returns what came in and went out, newest first. Filter by number with phoneNumberId, and by correspondent with conversationId. limit goes from 1 to 100. To page through, send the previous response’s nextCursor back in the cursor parameter. When it comes back null, you are done. The cursor is opaque: do not try to build one by hand.
curl -X GET "https://api.lunahia.com.br/v1/messages?conversationId=YOUR_CONVERSATION_ID&limit=20" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/messages?conversationId=YOUR_CONVERSATION_ID&limit=20', {
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?conversationId=YOUR_CONVERSATION_ID&limit=20",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())200 response (trimmed){
"data": [
{
"id": "ae60f288-7565-4315-a440-3663e9c231e1",
"direction": "outbound",
"wamid": "wamid.EXAMPLE_NOT_A_REAL_ID",
"type": "text",
"bodyText": "Olá! Recebemos seu pedido e já estamos preparando.",
"status": "delivered",
"waId": "5511999990000",
"displayNumber": "+55 11 99999-0000",
"createdAt": "2026-10-06T14:02:11.000Z"
}
],
"nextCursor": null
}Mark as read and show “typing”
Both routes act on a received message, and are synchronous: they answer 200 when Meta accepts. The reason is that a signal like this loses its value with time. messages:write scope.
curl -X POST "https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/read" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/read', {
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/read",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())curl -X POST "https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/typing" \
-H "Authorization: Bearer $LUNA_API_KEY"const res = await fetch('https://api.lunahia.com.br/v1/messages/YOUR_MESSAGE_ID/typing', {
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/typing",
headers={
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
},
timeout=10,
)
print(res.status_code, res.json())- A message you sent, a message that is not yours and a message that does not exist all answer
404, all alike. - Meta’s platform has no standalone typing indicator. Calling
/typingalso marks the message as read. There is no way to do one without the other. - The indicator goes away on its own when you reply, capped at 25 seconds on Meta’s side. There is no route to turn it off, and none is needed.
429, 409 or another error, see Errors. The full contract of POST /v1/messages is in the API reference, in the Messages section.