LunaDocumentaçãoEntrar

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, o metaPhoneNumberId que GET /v1/numbers devolve.
  • to é o telefone do destinatário em formato internacional, só dígitos, sem o sinal de mais. Por exemplo, 5511999990000.
  • A resposta é 202 com o id da mensagem na Luna e status: "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())
Resposta 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

OndeQual identificador
POST /v1/messages, fromO da Meta: metaPhoneNumberId.
POST /v1/numbers/{id}/mediaO da Luna: o id que GET /v1/numbers devolve.
GET /v1/messages, phoneNumberIdO 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_idO 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.

Resposta 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())
Resposta 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: true quer dizer que você pode mandar mensagem livre para aquele interlocutor agora. false quer dizer que o caminho é um template.
  • windowExpiresAt diz quando a janela fecha, e windowSource diz o que a abriu: usuario, template, anuncio ou desconhecido. Quando não há janela conhecida, os campos vêm nulos ou falsos, e nunca ausentes.
  • O id da conversa é o que GET /v1/messages?conversationId= aceita. O phoneNumberId do filtro é o identificador do número na Luna.
  • Uma limitação que vale conhecer: o agrupamento é pelo waId que 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())
Resposta 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())
Resposta 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 é 409 com error: "media_not_ready" e o estado atual, sem URL. pendente e falhou voltam 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())
Resposta 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, entregueEm e lidaEm são nulos enquanto a etapa não aconteceu. lidaEm também fica nulo quando o destinatário desliga a confirmação de leitura.
  • erros lista 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())
Resposta 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 /typing també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.
Ao receber 429, 409 ou outro erro, veja Erros. O contrato completo de POST /v1/messages está na referência da API, na seção Mensagens.