Erros e retry
O guia mais importante depois do de envio: tratar erro errado é o que transforma uma falha momentânea em mensagem perdida, ou em mensagem duplicada.
Os três envelopes
A API tem três formatos de erro. Não é elegante, mas é o que existe, e conhecer os três evita código que quebra quando encontra o formato inesperado.
1. O erro da v1, o caso comum
A requisição foi entendida, mas a operação não deu certo.
{
"success": false,
"error": {
"code": "INSTANCE_NOT_CONNECTED",
"message": "Instância não está conectada. Conecte via /qr primeiro."
}
}
2. O erro global, quando o corpo nem chega ao endpoint
Falha na validação de schema, e também os 429. Repare que aqui error é um booleano, e o
código está na raiz:
{
"error": true,
"code": "VALIDATION_ERROR",
"message": "body must have required property 'message'"
}
Fora de produção há também um campo stack. Não conte com ele.
3. O erro de plano (403)
O envelope da v1 com campos extras dizendo o que falta contratar:
{
"success": false,
"error": {
"code": "PLAN_FEATURE_NOT_ALLOWED",
"message": "...",
"feature": "message_queue",
"planCode": "basic",
"upgradeUrl": "/billing"
}
}
O
upgradeUrlaponta para a página de planos do painel. Os outros caminhos do mesmo envelope (renewUrl, quando o plano expirou, eextraInstancesUrl, quando a cota de instâncias estourou) apontam para o mesmo lugar.
Extraindo o código sem se preocupar com o formato
O mesmo endpoint pode produzir mais de um envelope, POST /messages/send produz os dois primeiros.
Um extrator que cobre todos:
function codigoDoErro(body) {
return body?.error?.code ?? body?.code ?? 'DESCONHECIDO';
}
Decida sempre pelo code, nunca pela message. A mensagem é texto para humanos e muda sem
aviso; o código é contrato.
O que retentar
| Código | Retentar? | Por quê |
|---|---|---|
INSTANCE_NOT_CONNECTED |
✅ Sim | A sessão caiu; costuma voltar sozinha |
SEND_RETRIABLE |
✅ Sim | Falha momentânea do motor de envio |
RATE_LIMITED |
✅ Sim | Aguarde o retryAfter indicado |
SEND_ERROR |
❌ Não | Falha permanente; repetir só gasta cota |
SEND_UNCONFIRMED |
❌ Nunca automaticamente | Não sabemos o desfecho: a mensagem pode ter sido entregue. Repetir duplica na tela de quem recebe |
VALIDATION_ERROR |
❌ Não | O corpo está errado; repetir dá o mesmo resultado |
PLAN_CHECK_UNAVAILABLE |
✅ Sim | 503: a verificação do plano falhou por instabilidade nossa, não é bloqueio |
AUTH_UNAVAILABLE |
✅ Sim | 503: não conseguimos conferir a chave agora (instabilidade nossa). A chave não é inválida: não troque |
SUBSCRIPTION_EXPIRED, TRIAL_EXPIRED, SUBSCRIPTION_REQUIRED |
❌ Não | A conta precisa de plano ativo; repetir não muda nada |
PLAN_FEATURE_NOT_ALLOWED |
❌ Não | O plano não inclui o recurso |
SEND_UNCONFIRMED, o caso do meio
Este não é "falhou". É "não sei", e a diferença importa: o WhatsApp às vezes aceita a
mensagem e não devolve o comprovante. Quando isso acontece, o servidor tenta achar a mensagem
no histórico da conversa; se acha, o envio vira sucesso normal, com messageId. Só quando
nem isso confirma é que sai o SEND_UNCONFIRMED, e aí não há messageId, porque ele é
justamente o que não foi possível obter.
Reenviar aqui é a pior escolha: o destinatário provavelmente já recebeu, e ele veria a mensagem duas vezes. O certo é marcar para conferência humana.
Regra geral de retry: decida por allowlist, retentando apenas os códigos marcados com
✅ acima. Regras por status HTTP ("todo 500 é transitório") são perigosas, porque
SEND_ERROR e SEND_UNCONFIRMED também respondem 500, e a segunda duplica mensagem se for
retentada.
A diferença entre SEND_RETRIABLE e SEND_ERROR é justamente essa classificação, o servidor já
analisou a falha e está dizendo se vale insistir:
{
"success": false,
"error": { "code": "SEND_RETRIABLE", "message": "Session closed" }
}
{
"success": false,
"error": { "code": "SEND_ERROR", "message": "Evaluation failed: t" }
}
Os demais códigos (MISSING_MEDIA, NOT_FOUND, WEBHOOK_NOT_FOUND, MISSING_INSTANCE, e os
outros listados em /docs) indicam problema permanente: algo faltando ou inexistente.
Não retente.
Falha depois do envio: o message.failed
Tudo acima é sobre a resposta do envio. Uma mensagem também pode falhar depois do 200,
quando o WhatsApp recusa a entrega; aí o aviso chega pelo webhook message.failed, com o motivo
em errorCode:
errorCode |
Reenviar? | Por quê |
|---|---|---|
FIRST_CONTACT_BLOCKED |
❌ Nunca automaticamente | Erro 463: o contato ainda não tem conversa com o número. Reenviar conta como mais um contato novo |
ACCOUNT_RESTRICTED |
❌ Nunca automaticamente | Erro 463 com o número restrito para iniciar conversas. Insistir prolonga a restrição |
DELIVERY_FAILED |
❌ Não automaticamente | Outra recusa do WhatsApp. Confira número e conteúdo antes de tentar de novo |
O DevZap também não reenvia essas mensagens por conta própria, nem pela fila. Veja o que fazer
em Erro 463 e acompanhe a situação do número em
GET /instances/{instanceId}/limits.
Uma política de retry que funciona
const RETRIAVEIS = new Set(['INSTANCE_NOT_CONNECTED', 'SEND_RETRIABLE', 'RATE_LIMITED']);
async function enviarComRetry(payload, { tentativas = 4 } = {}) {
for (let n = 0; n < tentativas; n++) {
const resposta = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
if (resposta.ok) return resposta.json();
const corpo = await resposta.json().catch(() => ({}));
const codigo = corpo?.error?.code ?? corpo?.code;
if (!RETRIAVEIS.has(codigo)) {
throw new Error(`falha permanente: ${codigo}`); // desiste na hora
}
// Respeita o retryAfter do 429; senão, backoff exponencial com jitter
const esperaBase = corpo.retryAfter ? corpo.retryAfter * 1000 : 2 ** n * 1000;
await new Promise((r) => setTimeout(r, esperaBase + Math.random() * 1000));
}
throw new Error('tentativas esgotadas');
}
Três detalhes que fazem diferença:
Desista cedo no erro permanente. Retentar VALIDATION_ERROR quatro vezes só atrasa a
descoberta de que o payload está errado.
Respeite o retryAfter. Ignorá-lo e insistir mantém você no limite indefinidamente.
Use jitter. Sem ele, várias falhas simultâneas retentam juntas e recriam o pico.
Retry e duplicação
O envio direto não é idempotente. Se a resposta se perdeu por timeout de rede, você não sabe se a mensagem saiu, e retentar pode duplicar.
Quando duplicar é inaceitável, use a fila com idempotencyKey:
{
"to": "5511123456789",
"idempotencyKey": "cobranca-4711-primeiro-aviso",
"type": "text",
"message": "Olá! Consta um débito em aberto."
}
Aí o retry é seguro por construção: a mesma chave nunca gera duas mensagens. A chave precisa vir do seu domínio, um UUID novo por tentativa não protege nada.
Erros de autenticação
| Código | HTTP | O que fazer |
|---|---|---|
MISSING_KEY, INVALID_FORMAT |
401 | Corrigir o cabeçalho; falta o Bearer ou a chave |
KEY_NOT_FOUND, KEY_INACTIVE, KEY_EXPIRED |
401 | Chave revogada ou vencida; gerar outra |
KEY_WRONG_AUDIENCE |
401 | É a chave que um assistente de IA recebeu pelo login: ela só vale no servidor MCP (/api/v1/mcp). Para a sua integração, crie uma chave em Chaves de API |
TENANT_SUSPENDED |
403 | Conta suspensa; falar com o suporte |
SCOPE_DENIED |
403 | A chave não tem permissão para esta operação |
AUTH_UNAVAILABLE |
503 | Falha nossa ao conferir a chave; repita com backoff, com a mesma chave |
Fora o AUTH_UNAVAILABLE, nenhum deles é retriável: repetir com a mesma chave dá o mesmo erro. Vale
alertar a operação em vez de insistir.
Erros do assistente de IA
O servidor MCP (/api/v1/mcp) devolve os mesmos códigos dentro do resultado da ferramenta, mais
três que são só dele: MCP_DISABLED, MCP_DAILY_CAP_REACHED e MCP_DUPLICATE_SEND. O que fazer
com cada um está em Assistentes de IA.
Guia verificado em 05/08/2026 contra a API v1. A tabela de retry é conferida automaticamente contra a classificação do código-fonte em cada build.