Operação
Erros
Todo erro do contrato /v1 tem exatamente a mesma forma, com
quatro campos. Você nunca precisa interpretar prosa nem adivinhar a partir do
status HTTP.
{
"code": "CHANNEL_UNREACHABLE",
"message": "O canal não respondeu a tempo. Nenhuma ação foi aplicada.",
"retryable": true,
"source": "canal"
}
| Campo | Para que serve |
|---|---|
code |
Identificador estável. É por ele — e só por ele — que o seu código decide o que fazer. |
message |
Prosa para humano. Livre para mudar a qualquer momento; nunca é contratual. |
retryable |
Se vale tentar de novo. Responde a pergunta sem heurística sobre status HTTP. |
source |
nuntis (problema nosso) ou canal (problema do provedor do canal). |
code, nunca por message
Comparar substring do texto quebra na primeira vez que alguém melhora a
redação — e essa quebra acontece silenciosamente, em produção, no seu
ramo de tratamento de erro. O code existe exatamente para você
não precisar disso.
retryable é por ocorrência, não por código
A tabela abaixo mostra o comportamento padrão de cada
código, mas uma resposta específica pode divergir — o mesmo código pode
nascer de causa transitória ou permanente. Leia sempre o
retryable da resposta, não a tabela.
Catálogo de códigos
code |
HTTP | retryable |
Significa |
|---|---|---|---|
VALIDATION_FAILED |
400 | não | Payload rejeitado: campo ausente, tipo errado, combinação proibida, campo desconhecido. Corrija e reenvie. |
UNAUTHORIZED |
401 | não | API key ausente, inválida, expirada, sem permissão para a rota, ou enviada por um meio não aceito. |
NOT_FOUND |
404 | não | Recurso inexistente ou de outro produto. Indistinguíveis de propósito. |
IDEMPOTENCY_IN_PROGRESS |
409 | sim | A mesma Idempotency-Key está sendo processada agora por outra requisição. A chave não queimou — tente de novo com backoff. |
IDEMPOTENCY_KEY_BURNED |
409 | não | A chave ficou em estado ambíguo e o sistema falha fechado. Use uma chave nova; nunca vira sucesso silencioso. |
RESOURCE_ALREADY_EXISTS |
409 | não | Já existe um recurso ativo seu, do mesmo tipo, com o externalRef informado. O recurso existente não é revelado: só você sabe se foi retry ou colisão. Desativar libera o valor. |
RESOURCE_NOT_EMPTY |
409 | não | O recurso ainda tem filhos ativos e não pode ser desativado. Não há cascata por desenho: desative de baixo para cima. |
PHONE_NUMBER_IN_USE |
409 | não | O telefone declarado já pertence a um número ativo desta plataforma — a checagem é global, e a resposta não diz de quem é. Desativar o número que o ocupa libera o valor. |
IDEMPOTENCY_KEY_REUSED |
422 | não | A mesma chave foi usada com payload diferente dentro da janela. Reenvie o payload original ou use chave nova. |
CHANNEL_CAPABILITY_UNSUPPORTED |
422 | não | O canal do número remetente não declara a capacidade exigida (tipo de mídia, resposta citada, nota de voz…). Consulte GET /v1/channels. |
OUTSIDE_MESSAGING_WINDOW |
422 | não | Houve conversa, mas a janela expirou. Em canais que suportam, o caminho é um template aprovado. |
CONVERSATION_NOT_GROUP |
422 | não | A conversa é 1:1, e a rota pediu a lista de participantes, que só existe em conversa de grupo. |
CONVERSATION_NOT_INITIATED |
422 | não | Nunca houve conversa. Nenhum template resolve — o único caminho é a pessoa enviar a primeira mensagem. O consentimento não expira. |
BROADCAST_DESTINATION_BLOCKED |
422 | não | Destino de broadcast/status recusado por política. |
NUMBER_DEACTIVATED |
422 | não | O número foi desativado (DELETE /v1/numbers/{id}). Ele continua visível com active: false e continua respondendo no status, mas não pareia, não sinaliza presença e não envia. Reativar não é self-service. |
GROUP_MEMBERSHIP_ENDED |
422 | não | O número não é mais participante daquele grupo, e a lista de participantes não pode ser consultada por ele. A conversa e o histórico continuam disponíveis. |
NUMBER_NOT_CONNECTED |
422 | não | O número está ativo, mas não tem sessão conectada no canal — e consultas que dependem da sessão (como a foto de perfil de um contato) não podem ser respondidas. Não confunda com CHANNEL_UNREACHABLE: aqui retry não resolve, porque nenhuma quantidade de tentativas pareia um telefone. Pareie o número e confira GET /v1/numbers/{id}/status. |
QUOTA_EXCEEDED |
422 | não | Um teto de provisionamento configurado para o seu produto foi atingido. Retry não resolve — desative o que não usa (desativado devolve quota) ou fale com o suporte. |
PAYLOAD_TOO_LARGE |
413 | não | O corpo da requisição passou de 10 MB. Como media.base64 infla o binário em ~33%, o limite prático do arquivo é ~7,5 MB. Retry não resolve: reduza o arquivo ou use media.url. |
MEDIA_TOO_LARGE |
422 | não | O arquivo recebido passa do teto de armazenamento de mídia do Nuntis e não será guardado. A decisão é nossa (source: nuntis), não do canal — e é final. Não confunda com PAYLOAD_TOO_LARGE, que fala do corpo da requisição que você envia. O recurso traz media.unavailable.reason: "too_large" com o size original. Detalhes. |
MEDIA_EXPIRED |
410 | não | O canal já não serve o binário desta mídia — o arquivo expirou do lado do WhatsApp. Estado final: nenhuma quantidade de tentativas o traz de volta, e nenhuma outra rota o recupera. O restante da mensagem (texto, legenda, metadados) continua íntegro, e o recurso traz media.unavailable.reason: "expired". Detalhes. |
MEDIA_RETENTION_EXPIRED |
410 | não | A política de retenção do Nuntis removeu o binário desta mídia: o arquivo de mensagem fica disponível por 90 dias a partir do timestamp da mensagem. A decisão é nossa (source: nuntis), não do canal. Mesmo status do MEDIA_EXPIRED — pare do mesmo jeito —, culpa oposta. O restante da mensagem continua íntegro, e o recurso traz media.unavailable.reason: "retention". Detalhes. |
RATE_LIMITED |
429 | sim | Limite de requisições da sua API key excedido — ou, na lista de participantes, o orçamento de consultas ao canal daquele número (a mensagem do erro diz qual). Respeite o header Retry-After — e não o X-RateLimit-Remaining-*, que é espelho e não garantia. O orçamento (minuto e hora), o formato de Reset e a janela de calendário estão em Orçamento de requisições. |
INTERNAL |
500 | sim | Falha inesperada do lado do Nuntis. Nenhum detalhe interno é exposto. |
CHANNEL_UNREACHABLE |
503 | sim | O canal está inalcançável ou não respondeu a tempo. Nenhuma ação foi aplicada. Tente de novo com backoff. No redownload de mídia ele significa exatamente uma coisa — não foi possível perguntar agora; “o arquivo não existe mais” é MEDIA_EXPIRED. |
MEDIA_STORAGE_UNAVAILABLE |
503 | sim | O armazenamento de mídia do Nuntis não respondeu ao subir o binário de media.base64. Nenhuma ação foi aplicada: a mensagem não foi criada e a sua Idempotency-Key continua válida — repita a mesma requisição com a mesma chave. Não confunda com CHANNEL_UNREACHABLE: aqui source é nuntis, a falha é nossa, e o canal pode estar perfeitamente de pé. Detalhes. |
PAIRING_QR_TIMEOUT |
504 | sim | O canal não produziu um código de pareamento dentro do tempo. Tente de novo. |
Trate um code que você não conhece como falha genérica e decida
o retry pelo retryable da resposta. Um switch que
lança exceção no default vai quebrar no dia em que um código
novo aparecer.
OUTSIDE_MESSAGING_WINDOW × CONVERSATION_NOT_INITIATED
Parecem o mesmo erro e não são — a diferença muda o que você deve fazer:
OUTSIDE_MESSAGING_WINDOW | CONVERSATION_NOT_INITIATED | |
|---|---|---|
| Houve conversa? | Sim, mas a janela expirou. | Não, nunca houve. |
| Template resolve? | Sim, em canais que suportam. | Não. Nenhum template reabre este caso. |
| O que fazer | Enviar por template, ou esperar o contato responder. | Levar a pessoa a mandar a primeira mensagem por outro caminho. |
Estratégia de retry
const MAX_TENTATIVAS = 5;
async function comRetry(operacao, { chaveDeEnvio } = {}) {
let atraso = 500; // ms
for (let tentativa = 1; tentativa <= MAX_TENTATIVAS; tentativa++) {
try {
// A MESMA Idempotency-Key em todas as tentativas — é isso que
// impede a mensagem de sair duas vezes.
return await operacao(chaveDeEnvio);
} catch (erro) {
// Decida SÓ por `retryable`. Nunca por status HTTP, nunca por texto.
if (!erro.retryable || tentativa === MAX_TENTATIVAS) throw erro;
// Jitter evita que mil clientes voltem no mesmo instante.
const espera = atraso + Math.random() * atraso;
await new Promise((r) => setTimeout(r, espera));
atraso *= 2;
}
}
}
- Backoff exponencial com jitter. Retry imediato em laço transforma uma indisponibilidade curta numa longa.
- Sempre a mesma
Idempotency-Keyem todas as tentativas do mesmo envio. Sem isso, o retry duplica a mensagem no aparelho do destinatário. - Respeite o
Retry-Afterquando vier429. - Não retente
retryable: false. O resultado será exatamente o mesmo, e você só gasta o seu limite.
Falha depois do aceite
Os códigos acima são erros da requisição. Uma mensagem já
aceita (202) pode falhar depois, no caminho até o canal — nesse
caso você recebe message.status com status: "failed"
e um bloco failure próprio:
"failure": {
"code": "CHANNEL_DISCONNECTED",
"retryable": true,
"at": "2026-08-13T18:12:04.000Z"
}
Vale a mesma regra: decida por retryable, trate código
desconhecido como falha genérica. A exceção que merece cuidado está descrita
em Estados de entrega —
DISPATCH_AMBIGUOUS significa que a mensagem
pode ter sido entregue, e reenviar pode duplicá-la.
DISPATCH_AMBIGUOUS, quem diz é o canal
O Nuntis reconcilia esse caso por dois mecanismos declarados em
GET /v1/channels:
idDeMensagemReservavel (o Nuntis escolhe o id da mensagem
antes de mandar, e depois reconhece a mensagem por ele) e
echoDeSaida (o canal devolve um eco do que saiu). Onde os dois
são true, uma mensagem que de fato saiu reaparece sozinha na
conversa e o estado se resolve.
Onde ambos são false, não há rede: o
DISPATCH_AMBIGUOUS é terminal, e
retryable: false é a palavra final. Não existe consulta que
desempate — a incerteza é do canal, não nossa. Reenviar por conta própria
ali pode duplicar a mensagem no aparelho de quem recebe.
Se o seu produto não pode conviver com esse desfecho, o lugar de tratá-lo é o seu: registre o caso e deixe a decisão de reenviar com uma pessoa, em vez de automatizá-la.