Enviar mensagens
Uma rota envia todos os tipos de mensagem: POST /v1/messages. O campo type diz qual é o tipo, e um corpo sem type é lido como texto. Este guia cobre os tipos, a regra da janela de 24 horas, a idempotência e como acompanhar a entrega.
O básico
- A chave de API precisa do escopo
messages:write. fromé o identificador do número na Meta, ometaPhoneNumberIdqueGET /v1/numbersdevolve.toé o telefone do destinatário em formato internacional, só dígitos, sem o sinal de mais. Por exemplo,5511999990000.- A resposta é
202com oidda mensagem na Luna estatus: "queued". Ela existe antes de a Meta responder.
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{
"id": "ae60f288-7565-4315-a440-3663e9c231e1",
"status": "queued"
}O texto vai até 4096 caracteres, o mesmo limite da Cloud API. Com preview_url: true em text, a Meta busca a primeira URL do texto para montar uma prévia. Ele vem desligado.
Por que `202` e não `200`
O limite que mais aperta na plataforma da Meta é de uma mensagem a cada seis segundos para o mesmo destinatário. Um agente que responde em rajada dispara isso sozinho. A Luna aceita, enfileira e entrega à Meta no ritmo que ela permite, e esse limite nunca volta para você como 429. O wamid e o estado de entrega chegam depois, pelo webhook.
Qual identificador usar
| Onde | Qual identificador |
|---|---|
POST /v1/messages, from | O da Meta: metaPhoneNumberId. |
POST /v1/numbers/{id}/media | O da Luna: o id que GET /v1/numbers devolve. |
GET /v1/messages, phoneNumberId | O da Luna: o id do número. |
GET /v1/messages/{id} | O id da mensagem na Luna, o mesmo que o 202 devolveu. |
reaction.message_id | O wamid da mensagem que recebe a reação. |
A janela de 24 horas
Texto livre só sai para quem escreveu para o seu número nas últimas 24 horas. Fora dessa janela, o único caminho é um template aprovado pela Meta, e enviar um template é o que reabre a conversa. Template e reação passam mesmo com a janela fechada.
A Luna aplica a regra por você: fora da janela, a mensagem livre é recusada com 409 antes de qualquer ida à Meta, porque uma chamada recusada lá é gasta à toa e conta como erro contra o seu número.
409{
"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."
}Esta resposta não traz Retry-After, e de propósito: a janela não abre com o tempo. Ela abre quando o contato escreve, ou quando um template é entregue. Repetir o pedido só repete a recusa.
Veja a janela antes de enviar
GET /v1/conversations devolve uma linha por interlocutor, por número, da mais ativa para a mais parada. Escopo messages:read.
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 (resumida){
"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: truequer dizer que você pode mandar mensagem livre para aquele interlocutor agora.falsequer dizer que o caminho é um template.windowExpiresAtdiz quando a janela fecha, ewindowSourcediz o que a abriu:usuario,template,anunciooudesconhecido. Quando não há janela conhecida, os campos vêm nulos ou falsos, e nunca ausentes.- O
idda conversa é o queGET /v1/messages?conversationId=aceita. OphoneNumberIddo filtro é o identificador do número na Luna. - Uma limitação que vale conhecer: o agrupamento é pelo
waIdque a plataforma gravou. Se o identificador que a Meta registrou difere do que foi digitado no envio, a mesma pessoa pode aparecer em duas conversas. - A resposta não traz contagem nenhuma: nem total, nem contador por interlocutor.
Idempotência
Mande o cabeçalho Idempotency-Key se você retenta. Com ele, repetir o pedido não produz segundo envio. O exemplo de texto acima já o manda, com um UUID novo a cada chamada.
- O cabeçalho é opcional. Quem não retenta não precisa dele.
- A chave é um valor que você escolhe para cada mensagem lógica. Um UUID para cada mensagem serve. Reuse a mesma chave só ao retentar a mesma mensagem.
- A Luna reconhece o pedido pela chave, e não pelo corpo. Se você reusar uma chave com outro corpo, a mensagem original é a que vale.
- O escopo da chave é o seu cliente: a mesma chave em dois clientes é aceita nos dois.
- A garantia vale também quando a retentativa atravessa a virada do mês.
Os tipos de mensagem
Todos usam POST /v1/messages com from, to e type. Abaixo, só o que muda em cada tipo. Todos, menos reação e template, exigem a janela aberta.
Texto
type: "text" (ou omitido), com text.body de 1 a 4096 caracteres. É o exemplo da seção anterior.
Imagem
type: "image", com image.link ou image.id, nunca os dois. caption de até 1024 caracteres. Veja mídia para escolher entre link e 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())Documento
type: "document", com document.link ou document.id. filename (até 240 caracteres) é o nome com que o arquivo aparece para quem recebe, e caption tem até 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())Vídeo
type: "video", com video.link ou video.id, e caption de até 1024 caracteres.
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())Áudio
type: "audio", com audio.link ou audio.id. Áudio não tem legenda.
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())Figurinha
type: "sticker", com sticker.link ou 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())Localização
type: "location", com latitude (de -90 a 90) e longitude (de -180 a 180), e name e address opcionais, de até 1000 caracteres.
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())Contatos
type: "contacts", com uma lista de um a vinte cartões. Cada cartão exige name.formatted_name, e aceita phones, emails, addresses, urls, org e birthday (AAAA-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())Reação
type: "reaction", com o wamid da mensagem que recebe a reação em reaction.message_id e o emoji. Mande emoji vazio para remover uma reação que você já tinha posto. Passa mesmo com a janela fechada.
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())Interativa: botões de resposta
type: "interactive" com interactive.type: "button". De um a três botões. O id de cada botão (até 256 caracteres) volta no webhook quando ele é tocado, e o title tem até 20 caracteres. O corpo (body.text) vai até 1024, e o rodapé (footer.text) até 60. O cabeçalho (header) é opcional, e pode ser texto (até 60 caracteres), imagem, vídeo ou documento.
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())Interativa: lista
interactive.type: "list". action.button é o rótulo do botão que abre a lista (até 20 caracteres). De uma a dez seções, e de uma a dez linhas por seção. Cada linha tem id (até 200), title (até 24) e description opcional (até 72). O title da seção tem até 24 caracteres.
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())Interativa: botão com link
interactive.type: "cta_url". Um botão com display_text (até 20 caracteres) e url (até 2048), em 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", com template.name e template.language.code (por exemplo pt_BR, que precisa bater com o idioma aprovado) e os components que preenchem as variáveis. É o que abre uma conversa fora da janela. Veja 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())Mídia
Imagem, vídeo, áudio, documento e figurinha aceitam duas formas de apontar o arquivo. Use uma ou outra, nunca as duas.
link: a URL pública do arquivo, que a Meta baixa no momento do envio. Ela precisa responder sem autenticação.id: o identificador de um arquivo que você já subiu. Com ele a Meta não precisa baixar nada da sua infraestrutura, o envio fica mais rápido, e você reaproveita o mesmo arquivo em várias mensagens. A Meta retém o arquivo enviado por 30 dias. Depois disso, suba de novo.
Subir um arquivo
POST /v1/numbers/{id}/media, em multipart/form-data, no campo file. O id na URL é o do número na Luna. Escopo messages:write. A resposta traz o mediaId, e não uma URL: a URL de download da Meta expira em cinco minutos, e uma URL guardada nasce errada.
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{
"mediaId": "1425835918205946"
}Um arquivo acima do teto da plataforma (100 MiB, o mesmo para todos os clientes) é recusado com 413 antes de qualquer ida à Meta.
Baixar a mídia que o cliente enviou
GET /v1/messages/{id}/media devolve uma URL assinada, emitida pela Luna, para a mídia que ela já guardou. Escopo messages:read.
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{
"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"
}- A URL expira em poucos minutos e é uma credencial de portador: quem a tiver baixa o arquivo, sem apresentar chave. Não a guarde, não a registre em log e não a mande por canal que você não controla. Quando ela expirar, chame a rota de novo.
- Se a mídia ainda não foi obtida, a resposta é
409comerror: "media_not_ready"e oestadoatual, sem URL.pendenteefalhouvoltam sozinhos, porque a varredura retenta dentro da janela de sete dias da Meta.expiradoé definitivo. - A URL aponta para o armazenamento da Luna, nunca para a Meta, e não tem o domínio da marca.
Acompanhe a entrega
O caminho principal é o webhook: cada mudança de estado chega como message.status. Veja Receber eventos. Para consultar uma mensagem na hora, ou reconciliar, use GET /v1/messages/{id} (escopo messages:read).
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{
"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"
}
}
}
]
}statusé o estado como a Meta o reportou por último, e é nulo enquanto nenhum evento de status chegou.enviadaEm,entregueEmelidaEmsão nulos enquanto a etapa não aconteceu.lidaEmtambém fica nulo quando o destinatário desliga a confirmação de leitura.erroslista o que deu errado, em ordem de acontecimento, e vem vazio quando nada deu. Veja Erros.- A resposta não traz contagem de tentativas, custo nem qualquer número de consumo.
O histórico
GET /v1/messages devolve o que entrou e saiu, do mais recente ao mais antigo. Filtre por número com phoneNumberId, e por interlocutor com conversationId. limit vai de 1 a 100. Para percorrer páginas, mande de volta o nextCursor da resposta anterior no parâmetro cursor. Quando ele vem nulo, acabou. O cursor é opaco: não tente construir um à mão.
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 (resumida){
"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
}Marcar como lida e mostrar “digitando”
As duas rotas agem sobre uma mensagem recebida, e são síncronas: respondem 200 quando a Meta aceita. A razão é que um sinal desses perde o valor com o tempo. Escopo messages:write.
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())- Mensagem que você enviou, mensagem que não é sua e mensagem que não existe respondem
404, todas iguais. - A plataforma da Meta não tem indicador de digitando isolado. Chamar
/typingtambém marca a mensagem como lida. Não há como fazer uma coisa sem a outra. - O indicador some sozinho quando você responde, com teto de 25 segundos do lado da Meta. Não há rota para desligá-lo, e não é preciso.
429, 409 ou outro erro, veja Erros. O contrato completo de POST /v1/messages está na referência da API, na seção Mensagens.