LunaDocsSign in

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:write scope.
  • from is the number’s identifier at Meta, the metaPhoneNumberId that GET /v1/numbers returns.
  • to is the recipient’s phone in international format, digits only, without the plus sign. For example, 5511999990000.
  • The response is 202 with the message’s id at Luna and status: "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

WhereWhich identifier
POST /v1/messages, fromMeta’s: metaPhoneNumberId.
POST /v1/numbers/{id}/mediaLuna’s: the id that GET /v1/numbers returns.
GET /v1/messages, phoneNumberIdLuna’s: the number’s id.
GET /v1/messages/{id}The message’s id at Luna, the same one the 202 returned.
reaction.message_idThe 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: true means you can send a free-form message to that correspondent now. false means the path is a template.
  • windowExpiresAt says when the window closes, and windowSource says what opened it: usuario (user), template, anuncio (ad) or desconhecido (unknown). When there is no known window, the fields come back null or false, never absent.
  • The conversation id is what GET /v1/messages?conversationId= accepts. The filter’s phoneNumberId is the number’s identifier at Luna.
  • A limitation worth knowing: grouping is by the waId the 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 409 with error: "media_not_ready" and the current estado, with no URL. pendente (pending) and falhou (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"
        }
      }
    }
  ]
}
  • status is the state as Meta last reported it, and it is null while no status event has arrived.
  • enviadaEm (sent), entregueEm (delivered) and lidaEm (read) are null while the step has not happened. lidaEm also stays null when the recipient turns read receipts off.
  • erros lists 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 /typing also 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.
On a 429, 409 or another error, see Errors. The full contract of POST /v1/messages is in the API reference, in the Messages section.