Erros
O formato de todo erro e o catálogo completo de códigos, com o que fazer em cada um.
O formato
Todo erro responde JSON com a mesma forma: error, um código estável feito pro seu código ler, e message, um texto em português feito pra uma pessoa ler no log. Alguns trazem campos extras (listados no catálogo). Trate pelo error; nunca pelo texto da mensagem, que pode mudar.
{
"error": "unauthorized",
"message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
}| Campo | Descrição |
|---|---|
errorstringsempre | Código estável, feito pro seu código ler. |
messagestringsempre | Texto em português, feito pra uma pessoa ler no log. |
O status HTTP acompanha o código (400 entrada, 401 credencial, 402 estado da conta, 403 permissão, 404 não existe, 409 conflito, 429 limite, 500 nosso). Toda resposta — sucesso ou erro — traz o header x-request-id.
Repetir ou não
Cada código diz o que fazer com a chamada. A regra curta:
| Classe | O que significa | O que fazer |
|---|---|---|
| 400 · 404 | A requisição está errada, ou o recurso não existe (ainda). | Corrija e repita. Em 404 de relatório, espere a ronda terminar. |
| 401 · 403 | Credencial, permissão ou recurso do plano. | Repetir sem mudar nada não resolve. 401 = confira variável, chave e plano · 403 = escopo que falta (insufficient_scope), recurso fora do plano (feature_locked), app sem verificação (domain_unverified) ou alvo bloqueado (target_blocked). |
| 402 · 409 | Estado da conta (cota, limite de apps, app gerenciado por integração). | Não é bug. Avise quem cuida da assinatura; em cota, refaça com ai: false. |
| 429 | Muitas rondas em pouco tempo. | Espere os segundos do header Retry-After e repita. |
| 500 | Erro nosso. | Repita com backoff exponencial (1 s, 2 s, 4 s…). Se persistir, mande o x-request-id pro suporte. |
Catálogo
Todos os códigos que a API pública devolve. A lista é fechada e testada contra o código: um código novo entra aqui antes de entrar em produção.
| HTTP | error | Onde | Ação |
|---|---|---|---|
| 400 | bad_request | qualquer rota | corrija e repita |
| 400 | invalid_target | POST /scans | corrija e repita |
| 400 | intrusive_unauthorized | POST /scans | corrija e repita |
| 400 | invalid_cursor | GET /scans | corrija e repita |
| 401 | unauthorized | qualquer rota | não repita |
| 402 | quota_exceeded | POST /scans | não repita |
| 402 | domain_limit | POST /scans | não repita |
| 403 | insufficient_scope | qualquer rota | não repita |
| 403 | feature_locked | POST /scans | não repita |
| 403 | target_blocked | POST /scans | não repita |
| 403 | domain_unverified | POST /scans | corrija e repita |
| 404 | not_found | GET /scans/{id}, POST /scans/{id}/cancel, GET /scans/{id}/report, GET /scans/{id}/report.pdf | corrija e repita |
| 409 | domain_provider_conflict | POST /scans | não repita |
| 429 | rate_limited | POST /scans | espere o Retry-After |
| 500 | internal | qualquer rota | repita com backoff |
400bad_request
Corpo malformado (JSON inválido). O status acompanha o motivo: 413 com corpo acima de 1 MB, 415 com Content-Type que não seja JSON — o código error continua bad_request.
O que fazer: Corrija a requisição. (corrija e repita)
Onde: qualquer rota
{
"error": "bad_request",
"message": "Unexpected token i in JSON at position 2"
}400invalid_target
target ausente ou não é URL completa (http:// ou https://).
O que fazer: Mande a URL inteira do alvo. (corrija e repita)
Onde: POST /scans
{
"error": "invalid_target",
"message": "Informe `target` como URL completa, começando com https://"
}400invalid_cursor
before não é uma data ISO 8601.
O que fazer: Use o next_before que veio na página anterior. (corrija e repita)
Onde: GET /scans
{
"error": "invalid_cursor",
"message": "`before` precisa ser uma data ISO 8601 (ex.: 2026-08-29T12:00:00.000Z) — use o `next_before` que veio na página anterior."
}402quota_exceeded
Cota de análises com IA do mês esgotada (só com ai: true).
O que fazer: Refaça com ai: false (ronda básica, sem cota) ou avise quem cuida da assinatura. Não é bug: é estado da conta. (não repita)
Onde: POST /scans
| Campo | Descrição |
|---|---|
planstringsempre | Plano atual. |
limitintegersempre | Cota do período. |
usedintegersempre | Quanto já foi usado. |
overagePricenumbersempre | R$ por análise extra (0 = sem excedente no plano). |
upgradeTostring | nullsempre | Plano sugerido. |
{
"error": "quota_exceeded",
"message": "Você usou suas 20 análises com IA do mês. Faça upgrade pra liberar mais.",
"plan": "pro",
"limit": 20,
"used": 20,
"overagePrice": 15,
"upgradeTo": "business"
}402domain_limit
Limite de apps do plano atingido ao escanear um hostname NOVO.
O que fazer: Remova um app no painel ou faça upgrade. Rondas em apps já cadastrados seguem funcionando. (não repita)
Onde: POST /scans
| Campo | Descrição |
|---|---|
limitintegersempre | Apps que o plano cobre. |
usedintegersempre | Apps em uso. |
upgradeTostring | nullsempre | Plano sugerido. |
{
"error": "domain_limit",
"message": "Seu plano cobre 10 apps. Remova um app em Apps ou faça upgrade pra escanear outro.",
"limit": 10,
"used": 10,
"upgradeTo": "business"
}403insufficient_scope
A chave é válida, mas não tem o escopo da rota.
O que fazer: Crie uma chave com o escopo em required e troque a variável de ambiente. Escopo não é editável e não chega com o tempo — retry nunca resolve. (não repita)
Onde: qualquer rota
| Campo | Descrição |
|---|---|
requiredstringsempre | O escopo que falta.scans:readscans:writeapps:read |
{
"error": "insufficient_scope",
"message": "Esta chave não tem a permissão \"scans:write\".",
"required": "scans:write"
}403feature_locked
A ronda pediu um recurso que o plano da conta não inclui: authenticated ou aggressive (authenticatedScan) ou lgpd (lgpdScan).
O que fazer: Tire a opção do corpo ou faça upgrade pro plano em upgradeTo. Não é bug: é estado da conta — e é avaliado a cada chamada, pelo plano daquele instante. (não repita)
Onde: POST /scans
| Campo | Descrição |
|---|---|
featurestringsempre | O recurso que faltou (authenticatedScan, lgpdScan). |
upgradeTostring | nullsempre | Plano sugerido. null quando não há plano acima. |
{
"error": "feature_locked",
"message": "Seu plano não inclui o modo agressivo.",
"feature": "authenticatedScan",
"upgradeTo": "pro"
}403target_blocked
Alvo na lista de bloqueio (órgão público, banco, grande plataforma) ou identificador interno de integração. Vale mesmo com a declaração de autorização da chave.
O que fazer: Não há o que fazer: a lista não é negociável. (não repita)
Onde: POST /scans
{
"error": "target_blocked",
"message": "Este alvo está na lista de bloqueio da Guarita — escaneie só apps que são seus."
}403domain_unverified
ai: true, authenticated: true ou aggressive: true num app sem prova de propriedade VIGENTE — nunca verificado, ou atestação de provedor vencida/revogada.
O que fazer: Verifique o app no painel (Apps → Verificar) ou refaça com ai: false e sem testes autenticados/agressivos. Trate como estado esperado no CI. (corrija e repita)
Onde: POST /scans
| Campo | Descrição |
|---|---|
hostnamestringsempre | O app em questão. |
{
"error": "domain_unverified",
"message": "Testes intrusivos ou autenticados exigem comprovar que app.exemplo.com.br é seu. Verifique a propriedade do app em Apps e tente de novo.",
"hostname": "app.exemplo.com.br"
}404not_found
Ronda inexistente, de outra conta, ou ainda sem relatório.
O que fazer: Confira o id. Em GET /scans/{id}/report e /report.pdf, espere a ronda terminar (status: done em GET /scans/{id}). (corrija e repita)
Onde: GET /scans/{id}, POST /scans/{id}/cancel, GET /scans/{id}/report, GET /scans/{id}/report.pdf
{
"error": "not_found",
"message": "Ronda não encontrada."
}409domain_provider_conflict
O hostname é de um projeto gerenciado por uma integração (Vercel ou Lovable).
O que fazer: Dispare a ronda pela tela da integração, no painel. (não repita)
Onde: POST /scans
| Campo | Descrição |
|---|---|
providerstringsempre | Qual integração gerencia o app.vercellovable |
{
"error": "domain_provider_conflict",
"message": "Este endereço já é gerenciado pela Vercel. Abra o app integrado em Apps em vez de cadastrá-lo novamente por URL.",
"provider": "vercel"
}429rate_limited
Muitas rondas em pouco tempo. O balde é por conta: 10 rondas de rajada, reabastecendo 1 por minuto.
O que fazer: Espere o tempo do header Retry-After (em segundos) e repita. Insistir antes só renova a espera. (espere o Retry-After)
Onde: POST /scans
| Campo | Descrição |
|---|---|
retryAfterSecintegersempre | Segundos até a próxima ronda caber. O mesmo valor vai no header Retry-After. |
{
"error": "rate_limited",
"message": "Muitas rondas em pouco tempo. Aguarde ~42s e tente de novo — o limite é de uso justo, pra manter a ronda básica grátis pra todo mundo.",
"retryAfterSec": 42
}500internal
Erro nosso.
O que fazer: Repita com backoff exponencial. Se persistir, mande o x-request-id da resposta pro suporte. (repita com backoff)
Onde: qualquer rota
{
"error": "internal",
"message": "Erro interno."
}Falar com o suporte
Escreva pra help@guarita.dev com o x-request-id da resposta, o endpoint e o horário. Com o id a gente acha a linha de log em segundos; sem ele, procura no escuro. Nunca mande a chave — o key_id de GET /me basta.
x-request-id, se for sobre uma resposta.