LunaDocsSign in

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 error and the status, never by the text of message.
  • Some errors add fields that say what to do: campos, fechadaEm, acaoRecomendada, estado, faltando, tentativasRestantes, janelaVira, reenfileiradoEm and credentialStatus. 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.
  • 404 also 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
400invalid_requestThe 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.
401unauthorizedThe 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.
403forbiddenThe 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.
403connection_not_releasedThe account cannot open a number connection yet. The faltando field lists the conditions (email_confirmado, conexao_liberada). Confirm the account’s e-mail.
404not_foundThe 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.
409window_closedThe 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.
409media_not_readyThe 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.
409no_webhook_endpointYou asked to replay a webhook and have no endpoint registered. Register one at POST /v1/webhooks/endpoints and ask again.
409already_requeuedThis item in the rejected queue was already requeued. reenfileiradoEm says when. Nothing to do.
409pin_not_applicableThe number is not waiting for the PIN. state says where it stands. See Connect a number.
409register_budget_reservedThe 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.
409reconnect_not_applicableThis number’s authorization does not allow reconnection. credentialStatus says the state.
413file_too_largeThe 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.
429rate_limitedYou went over the key’s request limit. Wait the Retry-After time. See the next section.
429queue_fullThis number’s send queue is full. Wait the Retry-After time and try again.
429number_rate_limitedThis number is at its limit of calls per second. Wait the Retry-After time.
429management_busyThe connection’s management operations (creating and editing templates, for example) hit the momentary limit. Wait the Retry-After time.
500internalUnexpected 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.
503unavailableThe 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.

HeaderWhat it carries
X-RateLimit-LimitThe window’s ceiling.
X-RateLimit-RemainingHow many requests still fit in the window.
X-RateLimit-ResetIn how many seconds the window starts over.
Retry-AfterOn 429 responses: how many seconds to wait before trying again.
  • The three X-RateLimit-* headers accompany successful responses. Read X-RateLimit-Remaining and slow down before it reaches zero.
  • On a 429, wait the Retry-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` fieldWhat it is
codigoMeta’s error code, intact. Null when Meta did not answer: a timeout, for example, has no code.
categoriaThe 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.
mensagemWhat happened, in Portuguese. It is Helsen’s text, not a translation of Meta’s message.
acaoRecomendadaWhat to do, in one actionable sentence.
originalMeta’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 the codigo and the original.
  • erros gathers 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: the acaoRecomendada is 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_id inside original is what moves a ticket forward.
The API reference carries each route’s contract and body examples, in the API reference.