Errors
Every API error returns a JSON body with error and message. The error is a stable code, meant for your code to decide what to do. The message is text for a person to read, in Portuguese.
The error shape
400{
"error": "invalid_request",
"message": "Requisição inválida.",
"campos": [{ "campo": "text.body" }]
}- Decide by the
errorand the status, never by the text ofmessage. - Some errors add fields that say what to do:
campos,fechadaEm,acaoRecomendada,estado,faltando,tentativasRestantes,janelaVira,reenfileiradoEmandcredentialStatus. They are in the table below. - No error echoes back the value you sent. On a
400, what comes out is the path of the refused field, never its content. 404also stands for a resource that exists and is not yours. The API does not confirm the existence of another customer’s resource, not even by status.
The main codes
| Status | `error` | What it means, and what to do |
|---|---|---|
| 400 | invalid_request | The body or query does not match the contract. The campos field lists the path of each refused field. Fix it and send again. Repeating the same request repeats the refusal. |
| 401 | unauthorized | The key is missing, malformed, nonexistent, expired or revoked. The body is always the same, without saying which. Check the Authorization: Bearer hlsn_... header and whether the key was revoked. |
| 403 | forbidden | The key lacks the route’s scope. Create a key with the right scope, and give each key only what it needs. The answer talks about the verb, never about the resource. |
| 403 | connection_not_released | The account cannot open a number connection yet. The faltando field lists the conditions (email_confirmado, conexao_liberada). Confirm the account’s e-mail. |
| 404 | not_found | The resource does not exist, or is not yours. The two answers are identical, byte for byte. Check the identifier, and whether it is Luna’s or Meta’s for this route. |
| 409 | window_closed | The 24-hour window is closed. fechadaEm says when it closed, and acaoRecomendada says the path: send a template. There is no Retry-After, and repeating does not help. See Send messages. |
| 409 | media_not_ready | The media has not been fetched yet. estado says where it stands: pendente and falhou come back on their own, expirado is final. Try again later. |
| 409 | no_webhook_endpoint | You asked to replay a webhook and have no endpoint registered. Register one at POST /v1/webhooks/endpoints and ask again. |
| 409 | already_requeued | This item in the rejected queue was already requeued. reenfileiradoEm says when. Nothing to do. |
| 409 | pin_not_applicable | The number is not waiting for the PIN. state says where it stands. See Connect a number. |
| 409 | register_budget_reserved | The registration attempts Meta grants the number in 72 hours are near the end. tentativasRestantes and janelaVira say how many are left and when the window turns over. Confirm the PIN with the end business before trying again. |
| 409 | reconnect_not_applicable | This number’s authorization does not allow reconnection. credentialStatus says the state. |
| 413 | file_too_large | The file goes over the upload ceiling, which is the same for every customer. The refusal happens before any call to Meta. Send a smaller file. |
| 429 | rate_limited | You went over the key’s request limit. Wait the Retry-After time. See the next section. |
| 429 | queue_full | This number’s send queue is full. Wait the Retry-After time and try again. |
| 429 | number_rate_limited | This number is at its limit of calls per second. Wait the Retry-After time. |
| 429 | management_busy | The connection’s management operations (creating and editing templates, for example) hit the momentary limit. Wait the Retry-After time. |
| 500 | internal | Unexpected failure on Luna’s side. The body carries no detail, and the detail stays in the internal log. Try again, and if it persists, ask for help with the time and the message identifier. |
| 503 | unavailable | The platform cannot serve right now. Try again with a growing wait. This same body also shows up, on purpose, when you try to connect an account or number that already belongs to another customer: the API does not confirm the existence of another’s resource. |
The four 429 codes come from different places, and the response carries Retry-After, in seconds. If it is missing, wait with a growing backoff, as the code below does. Luna’s 429 is never Meta’s pacing limit handed back to you: that limit is resolved by queue, which is why sending answers 202.
The request limit
Each API key can make 600 requests per minute. The limit is technical and identical for every customer. No limit on this platform varies with price. A request without a key is counted by its source address.
| Header | What it carries |
|---|---|
X-RateLimit-Limit | The window’s ceiling. |
X-RateLimit-Remaining | How many requests still fit in the window. |
X-RateLimit-Reset | In how many seconds the window starts over. |
Retry-After | On 429 responses: how many seconds to wait before trying again. |
- The three
X-RateLimit-*headers accompany successful responses. ReadX-RateLimit-Remainingand slow down before it reaches zero. - On a
429, wait theRetry-After. Retrying right away is only refused again. - Account sign-up has a ceiling of its own: 5 per hour per source address.
Retrying safely
Retry only 429 and 503, and wait the Retry-After. For POST /v1/messages, always send the Idempotency-Key, and the same one on every attempt: without it, a retry after a timeout can send the message twice. Do not retry 400, 401, 403, 404 or 409: the answer does not change.
const BASE = 'https://api.lunahia.com.br';
async function lunaFetch(path, options = {}, attempts = 5) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.LUNA_API_KEY}`,
'Content-Type': 'application/json',
...options.headers,
},
});
const retryable = res.status === 429 || res.status === 503;
if (!retryable || attempt === attempts) return res;
// Retry-After is in seconds. Without it, back off 2, 4, 8... seconds.
const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}
// One Idempotency-Key for all attempts, so a retry never sends twice.
const res = await lunaFetch('/v1/messages', {
method: 'POST',
headers: { 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({
from: 'YOUR_PHONE_NUMBER_ID',
to: '5511999990000',
text: { body: 'Olá!' },
}),
});
const body = await res.json();
if (!res.ok) {
console.error(res.status, body.error, body.message);
} else {
console.log(res.status, body);
}import os
import time
import uuid
import requests
BASE = "https://api.lunahia.com.br"
def luna_request(method, path, attempts=5, **kwargs):
headers = {
"Authorization": f"Bearer {os.environ['LUNA_API_KEY']}",
**kwargs.pop("headers", {}),
}
for attempt in range(1, attempts + 1):
res = requests.request(
method, BASE + path, headers=headers, timeout=10, **kwargs
)
if res.status_code not in (429, 503) or attempt == attempts:
return res
# Retry-After is in seconds. Without it, back off 2, 4, 8... seconds.
time.sleep(int(res.headers.get("Retry-After", 2**attempt)))
# One Idempotency-Key for all attempts, so a retry never sends twice.
res = luna_request(
"POST",
"/v1/messages",
headers={"Idempotency-Key": str(uuid.uuid4())},
json={
"from": "YOUR_PHONE_NUMBER_ID",
"to": "5511999990000",
"text": {"body": "Olá!"},
},
)
body = res.json()
if not res.ok:
print(res.status_code, body["error"], body["message"])
else:
print(res.status_code, body)When the message is accepted and then fails
The send’s 202 means Luna accepted the message, not that it was delivered. The failure can come later, from Meta. You see it two ways: in the webhook, with a message.status of status: "failed" and the error_code, and in GET /v1/messages/{id}, in the erros field.
{
"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"
}
}
}| `erros` field | What it is |
|---|---|
codigo | Meta’s error code, intact. Null when Meta did not answer: a timeout, for example, has no code. |
categoria | The error’s nature, in Luna’s vocabulary. The values are parametro_invalido, reauth_required, recurso_inacessivel, orcamento_management, throughput_excedido, estado_de_produto, janela_de_atendimento, midia_indisponivel, nao_entregavel, template_invalido, ritmo_por_destinatario, terminal, transporte, contrato and desconhecido. |
mensagem | What happened, in Portuguese. It is Helsen’s text, not a translation of Meta’s message. |
acaoRecomendada | What to do, in one actionable sentence. |
original | Meta’s error body, with no rewriting and no filtering. It is what Meta’s support recognizes. |
desconhecido(unknown) is literal and deliberate: Meta adds codes without notice, and a code not yet mapped arrives under that category rather than being hidden. Read thecodigoand theoriginal.errosgathers what Meta refused at send time and what it reported afterwards, through a status event. You do not need to know which of the two each item came from: theacaoRecomendadais the one that fixes it.- Take care when passing `original` on. It can contain the end consumer’s phone number and parts of the conversation. Treat it with the same care as the rest of the message content before sending it to third parties.
- When asking Meta for help, the
fbtrace_idinsideoriginalis what moves a ticket forward.