← Documentação da API

Referência de endpoints

A API pública vive sob https://api.guarita.dev/v1/public. São seis endpoints, e essa escassez é de propósito: aqui só entra o que faz sentido automatizar — listar apps, listar rondas, ler uma ronda, ler o relatório e disparar uma ronda. Cobrança, gestão de time, dados fiscais e exclusão de conta ficam fora, porque uma chave vazada não pode virar dano irreversível.

Convenções

Autenticação. Toda requisição leva a chave no header Authorization:

bash
curl https://api.guarita.dev/v1/public/me \
  -H "Authorization: Bearer gsk_SUACHAVE"

Sem chave válida, a resposta é 401 com o header WWW-Authenticate. A chave é resolvida por hash a cada requisição — revogar tem efeito imediato, sem cache e sem token auto-contido sobrevivendo por aí.

Escopos. Cada endpoint exige um escopo, e a chave só carrega os que você marcou na criação. Uma chave de leitura vazada não dispara ronda nem consome cota de IA.

EscopoO que permiteEndpoints
scans:readLer rondas e relatóriosGET /me, GET /scans, GET /scans/:id, GET /scans/:id/report
scans:writeDisparar novas rondasPOST /scans
apps:readListar os apps monitoradosGET /apps

Datas. Todo campo de data é ISO 8601 em UTC (2026-08-27T14:03:11.204Z).

Nomes de campo. As respostas destes endpoints usam snake_case — é o formato do contrato publicado, e ele não muda quando a tela muda. Duas exceções, ambas visíveis nos exemplos abaixo: o objeto summary de GET /scans/:id e o corpo inteiro de GET /scans/:id/report são o resultado do scanner, que tem um formato próprio e versionado (schemaVersion), com camelCase na estrutura da ronda e snake_case dentro do bloco ai. Preferimos entregar o documento como ele é a reescrevê-lo e correr o risco de o relatório da API divergir do relatório do painel.

Plano. A API faz parte de "Webhook + API", incluído a partir do Pro. O plano é reavaliado a cada requisição: se a conta cair para um plano sem a feature, as chaves param de responder na hora (401), sem varredura revogando nada.


GET /me

Ping autenticado. Confirma que a chave funciona e mostra o que ela pode — é o primeiro teste que vale fazer depois de colar a chave numa variável de ambiente, e é um bom health check para o seu CI antes de gastar uma ronda.

  • Escopo: scans:read
  • Parâmetros: nenhum
bash
curl https://api.guarita.dev/v1/public/me \
  -H "Authorization: Bearer $GUARITA_API_KEY"
json
{
  "account_id": "acc_9f2c1b7e",
  "key_id": "key_3a8d41c6",
  "scopes": ["scans:read", "scans:write", "apps:read"],
  "plan": "pro"
}

plan é o plano efetivo da conta no momento da chamada — o mesmo que decide cota e features. Na prática ele vem pro ou business, porque são os planos que incluem a API; se a conta tivesse caído para free ou starter, a chave nem teria autenticado.

key_id é útil no log da sua integração: quando você tem três chaves, é ele que responde "qual delas está rodando neste job?" sem precisar imprimir o segredo.


GET /apps

Os apps que a conta monitora. Um app entra nesta lista na primeira ronda contra aquele hostname (ou pela integração com Vercel/Lovable), e ocupa uma vaga do limite do plano.

Dois casos NÃO aparecem aqui, e vale saber antes de tratar esta lista como inventário completo: projeções da Vercel cuja autorização foi revogada ou expirou (ficam no banco como histórico, mas não são mais app protegido) e domínios que só ocupam cota. Se um hostname que você esperava sumiu da lista, é quase sempre autorização revogada — não exclusão.

  • Escopo: apps:read
  • Parâmetros: nenhum
bash
curl https://api.guarita.dev/v1/public/apps \
  -H "Authorization: Bearer $GUARITA_API_KEY"
json
{
  "data": [
    {
      "hostname": "app.exemplo.com.br",
      "created_at": "2026-05-14T09:21:44.108Z",
      "last_scan_at": "2026-08-27T14:03:11.204Z",
      "verified": true
    },
    {
      "hostname": "staging.exemplo.com.br",
      "created_at": "2026-07-02T18:40:02.771Z",
      "last_scan_at": null,
      "verified": false
    }
  ]
}
CampoTipoDescrição
hostnamestringHostname normalizado: minúsculo, sem www e sem porta.
created_atstringQuando o app foi registrado na conta.
last_scan_atstring \nullÚltima ronda contra este app. null = nunca escaneado.
verifiedbooleanA propriedade do domínio foi comprovada (DNS TXT, arquivo, meta tag ou atestação do provedor).

verified é o campo que mais importa para automação, e a razão está no endpoint seguinte: análise com IA só roda em app verificado. Se você vai disparar rondas com IA a partir do CI, checar este booleano antes evita um 403 que você não tem como resolver dentro do pipeline.

A lista não é paginada — o número de apps é limitado pelo plano (Pro: 10; Business: ilimitado), então ela cabe numa resposta.


GET /scans

Histórico de rondas da conta, da mais recente para a mais antiga, paginado por cursor.

  • Escopo: scans:read
ParâmetroTipoPadrãoDescrição
limitinteiro20Quantas rondas por página. Teto de 50; valores acima são reduzidos a 50, e valor ausente, inválido ou ≤ 0 cai no padrão 20.
beforestring ISOCursor: devolve só rondas criadas antes deste instante.
bash
curl "https://api.guarita.dev/v1/public/scans?limit=2" \
  -H "Authorization: Bearer $GUARITA_API_KEY"
json
{
  "data": [
    {
      "id": "scan_7b3e9a12",
      "hostname": "app.exemplo.com.br",
      "status": "done",
      "created_at": "2026-08-27T14:02:56.902Z",
      "finished_at": "2026-08-27T14:03:11.204Z",
      "risk_score": 78,
      "risk_label": "HIGH_RISK",
      "critical_count": 2,
      "blocked": false
    },
    {
      "id": "scan_1c40de55",
      "hostname": "staging.exemplo.com.br",
      "status": "done",
      "created_at": "2026-08-21T11:15:03.517Z",
      "finished_at": "2026-08-21T11:15:29.880Z",
      "risk_score": 34,
      "risk_label": "MODERATE_RISK",
      "critical_count": 0,
      "blocked": false
    }
  ],
  "next_before": "2026-08-21T11:15:03.517Z"
}
CampoTipoDescrição
idstringId da ronda (scan_…), usado nos endpoints de detalhe e relatório.
hostnamestringAlvo normalizado.
statusstringqueued, running, analyzing, done, failed ou canceled.
created_atstring \nullQuando a ronda foi criada. É também o valor do cursor.
finished_atstring \nullQuando terminou. null enquanto roda.
risk_scorenúmeroExposição de 0 a 100 (quanto maior, pior).
risk_labelstringSECURE, LOW_RISK, MODERATE_RISK, HIGH_RISK ou CRITICAL_RISK.
critical_countnúmeroQuantos achados de severidade critical.
blockedbooleanA ronda não conseguiu ver o app (rede bloqueada ou desafio de WAF/anti-bot).

risk_score e critical_count andam juntos porque são o par que uma automação usa para decidir se derruba o build. risk_label vai junto porque o número sozinho não é acionável: 62 não diz nada sem a faixa em que ele cai.

Ronda com status diferente de done ainda não tem números reais — enquanto ela roda, risk_score vem 0 e risk_label vem SECURE, que é o resumo mínimo para o histórico mostrar o estado, não um veredito. Só leia a nota quando status for done.

blocked: true merece tratamento próprio no seu pipeline: significa que a Guarita não conseguiu enxergar o app, então a nota não reflete a segurança dele. Falhar o build por causa disso pune o time errado.

Paginação por cursor

next_before é o created_at do último item da página. Para pegar a próxima, mande esse valor em before:

bash
curl "https://api.guarita.dev/v1/public/scans?limit=2&before=2026-08-21T11:15:03.517Z" \
  -H "Authorization: Bearer $GUARITA_API_KEY"
json
{
  "data": [
    {
      "id": "scan_a90f2b77",
      "hostname": "app.exemplo.com.br",
      "status": "done",
      "created_at": "2026-08-14T09:02:10.334Z",
      "finished_at": "2026-08-14T09:02:41.019Z",
      "risk_score": 91,
      "risk_label": "CRITICAL_RISK",
      "critical_count": 4,
      "blocked": false
    }
  ],
  "next_before": null
}

O cursor é exclusivo (created_at < before), então a ronda que fechou a página anterior não aparece de novo.

next_before vem null quando a página trouxe menos itens do que o limit pedido — o sinal de que o histórico acabou. É por isso que a última página do exemplo acima tem um item para um limit=2. O caso de borda: se o total for múltiplo exato do limit, a última página vem cheia e next_before vem preenchido; a requisição seguinte devolve data: [] e next_before: null. Escreva o laço parando em next_before === null, não em data.length < limit.

Cursor por data, e não por offset, porque o histórico recebe rondas novas no topo o tempo todo. Com offset, uma ronda disparada no meio da sua paginação empurraria a lista e você leria o mesmo registro duas vezes.

js
// Percorre o histórico inteiro, página por página.
async function todasAsRondas(chave) {
  const rondas = [];
  let before = null;

  for (;;) {
    const url = new URL("https://api.guarita.dev/v1/public/scans");
    url.searchParams.set("limit", "50");
    if (before) url.searchParams.set("before", before);

    const res = await fetch(url, { headers: { Authorization: `Bearer ${chave}` } });
    if (!res.ok) throw new Error(`Guarita respondeu ${res.status}: ${await res.text()}`);

    const { data, next_before } = await res.json();
    rondas.push(...data);
    if (!next_before) return rondas;
    before = next_before;
  }
}

Um detalhe que evita surpresa: o histórico é podado pela retenção do plano (Pro: 365 dias; Business: sem limite). Rondas mais antigas que isso não estão "numa página seguinte" — elas não existem mais.


GET /scans/:id

Estado e resumo de uma ronda. É o endpoint de polling depois de um POST /scans.

  • Escopo: scans:read
  • Parâmetro de rota: id — o scan_… devolvido na criação
bash
curl https://api.guarita.dev/v1/public/scans/scan_7b3e9a12 \
  -H "Authorization: Bearer $GUARITA_API_KEY"
json
{
  "id": "scan_7b3e9a12",
  "hostname": "app.exemplo.com.br",
  "target": "https://app.exemplo.com.br",
  "status": "done",
  "created_at": "2026-08-27T14:02:56.902Z",
  "finished_at": "2026-08-27T14:03:11.204Z",
  "summary": {
    "id": "scan_7b3e9a12",
    "target": "https://app.exemplo.com.br",
    "hostname": "app.exemplo.com.br",
    "status": "done",
    "score": 78,
    "riskLabel": "HIGH_RISK",
    "criticalCount": 2,
    "finishedAt": "2026-08-27T14:03:11.204Z",
    "durationMs": 14200,
    "blocked": false
  }
}

target é a URL exata que você mandou escanear; hostname é a forma normalizada dela, e é por ele que a cota de apps é contada.

summary é null enquanto a ronda não produziu resultado — ou seja, em queued, running e analyzing, e também numa ronda failed que morreu antes de gerar qualquer coisa. Enquanto isso, o que você tem é status. Uma ronda em andamento fica assim:

json
{
  "id": "scan_e11a03d9",
  "hostname": "app.exemplo.com.br",
  "target": "https://app.exemplo.com.br",
  "status": "running",
  "created_at": "2026-08-29T10:41:07.552Z",
  "finished_at": null,
  "summary": null
}

Dentro de summary, blockedReason aparece quando blocked é true, com dois valores possíveis, e a distinção importa:

  • connection: a ronda não alcançou o app a partir dos nossos servidores. O problema é do nosso lado da rede, não da sua configuração — não há nada para você liberar.
  • challenge: o app respondeu com página de desafio ou 403 de WAF/anti-bot. Aí sim vale liberar a Guarita no seu provedor.

Ronda bloqueada não consome cota de IA. Não cobramos por uma ronda que não viu o app.

Um id de outra conta responde 404, igual a um id inexistente — a API não confirma a existência de rondas de terceiros.


GET /scans/:id/report

O relatório completo da ronda: achados, evidências, análise da IA, inventário de infra e plano de correção. É o mesmo documento que o painel mostra e o PDF imprime.

  • Escopo: scans:read
  • Parâmetro de rota: id
bash
curl https://api.guarita.dev/v1/public/scans/scan_7b3e9a12/report \
  -H "Authorization: Bearer $GUARITA_API_KEY"

Resposta (recortada — um relatório real tem dezenas de achados):

json
{
  "schemaVersion": "1.0",
  "scan": {
    "id": "scan_7b3e9a12",
    "target": "https://app.exemplo.com.br",
    "hostname": "app.exemplo.com.br",
    "stack": ["Lovable", "Supabase", "Vercel"],
    "status": "done",
    "startedAt": "2026-08-27T14:02:57.010Z",
    "finishedAt": "2026-08-27T14:03:11.204Z",
    "durationMs": 14200,
    "modulesRun": 23
  },
  "findings": [
    {
      "id": "SECRETS-001",
      "module": "secrets",
      "severity": "critical",
      "cvss": 9.8,
      "title": "Supabase service_role key exposta no bundle JS",
      "description": "Chave de serviço (bypassa RLS) encontrada em index-CBWCsxTw.js. Concede acesso administrativo total à API de dados.",
      "evidence": "eyJhbGciOiJIUzI1Ni…  ·  role: \"service_role\"  ·  fonte: app.exemplo.com.br/assets/index-CBWCsxTw.js",
      "recommendation": "Rotacione a service_role e use apenas a anon key no client.",
      "confidence": "firm",
      "friendly": {
        "headline": "Sua \"senha mestra\" do banco está exposta no site",
        "whatItIs": "A `service_role` é a chave de administrador do seu Supabase. Ela está escrita no JavaScript que qualquer visitante baixa só de abrir seu site.",
        "whyItMatters": "Com ela, qualquer pessoa pode ler, alterar ou apagar todos os dados do seu app.",
        "urgency": "now"
      },
      "fix": {
        "kind": "code",
        "targets": ["lovable", "cursor", "v0", "bolt"],
        "prompt": "Remova a chave service_role do Supabase de todo o código do front-end…",
        "code": "const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)"
      }
    }
  ],
  "ai": {
    "executive_summary": "O app expõe uma credencial administrativa do banco e opera com RLS desligado…",
    "risk_score": {
      "score": 78,
      "label": "HIGH_RISK",
      "justification": "Credencial privilegiada exposta + RLS desabilitado permitem takeover total do banco."
    },
    "risk_scenarios": [],
    "owasp_mapping": [
      { "category": "A02:2021-Cryptographic Failures", "finding_ids": ["SECRETS-001"], "status": "vulnerable" }
    ],
    "remediation_plan": [
      {
        "priority": 1,
        "finding_ids": ["SECRETS-001"],
        "action": "Esconder a chave de administrador do Supabase",
        "effort": "quick_win",
        "impact_if_not_fixed": "Acesso total ao banco por qualquer visitante."
      }
    ],
    "additional_insights": [],
    "infra_inventory": [
      {
        "provider": "supabase",
        "asset": "plsvzxvydhkvlxbtxgrs.supabase.co",
        "kind": "baas_project",
        "exposure": "credential_leaked",
        "notes": "service_role exposta no bundle."
      }
    ],
    "cross_infra_chains": [],
    "discovered_vulnerabilities": []
  },
  "infra": {
    "assets": [],
    "credentials": [],
    "providers": ["supabase", "gcp", "vercel"],
    "scopeHosts": ["app.exemplo.com.br", "plsvzxvydhkvlxbtxgrs.supabase.co"],
    "notes": []
  },
  "strengths": [
    "HTTPS ativo e certificado válido",
    "Nenhuma chave do Stripe exposta"
  ]
}

Campos de um achado (findings[]):

CampoTipoDescrição
idstringId do achado dentro desta ronda (ex.: SECRETS-001). Renumera entre rondas — para casar achados entre rondas use module + title.
modulestringVerificação que produziu o achado (secrets, cors, headers, supabase…).
severitystringcritical, high, medium, low ou info.
cvssnúmeroPontuação CVSS do achado.
titlestringTítulo técnico.
descriptionstringO que foi encontrado, em detalhe.
evidencestringA prova observada: requisição, resposta, trecho do bundle.
recommendationstringRecomendação curta (não é o conserto pronto).
confidencestringconfirmed (provado por observação ativa), firm (detecção determinística) ou tentative (heurística que pede confirmação). Ausente ⇒ trate como firm.
goodbooleanAchado positivo: uma boa prática comprovada, não um problema.
linksstring[]Ids de achados relacionados (hoje, os elos de uma cadeia de ataque).
friendlyobjetoVersão em linguagem de produto: headline, whatItIs, whyItMatters, urgency (now/soon/later).
fixobjetoO conserto pronto. Ver abaixo.

O bloco fix traz prompt (texto autossuficiente para colar num agente de código), code (trecho pronto, quando aplicável), targets (ferramentas para as quais o prompt foi escrito) e kind, que diz o tipo de ação: code, dns, infra, action, confirm (como confirmar um achado não confirmado antes de mexer) ou chain (fechar qualquer elo de uma cadeia).

O conserto depende do plano

Esta rota passa pelo mesmo preparo do painel e do PDF antes de responder. O gate é unlocksFixPrompts: qualquer plano com cota de IA diferente de zero vê o conserto inteiro; uma conta sem cota fica no paywall. A regra vale para a conta, retroativamente, em todas as rondas — inclusive as antigas.

Quando o conserto é retido, o campo fix não vem e no lugar dele aparece fixWithheld: true:

json
{
  "id": "CORS-001",
  "module": "cors",
  "severity": "high",
  "title": "CORS reflete qualquer Origin com credenciais",
  "evidence": "Origin: https://evil.test  →  Access-Control-Allow-Origin: https://evil.test",
  "fixWithheld": true
}

O marcador existe por um motivo específico: sem ele, um fix ausente faria o consumidor concluir que a ronda não rodou análise com IA — uma afirmação falsa sobre o que foi entregue. fixWithheld: true diz "o conserto existe, mas não está nesta resposta".

Dois campos aparecem no mesmo contexto:

  • fixSample: true — este achado é a amostra grátis da conta, e o conserto veio inteiro mesmo sem plano. A amostra é uma por conta (o mais grave dos achados retidos), não uma por ronda.
  • fixFromCatalog: true — o conserto veio do catálogo determinístico, não da IA. Serve para não anunciar "análise com IA" numa ronda em que a IA não rodou.

Hoje, todo plano que inclui a API também destrava o conserto, então na prática você recebe fix preenchido. Não trate isso como garantia do contrato: o gate é avaliado a cada requisição, a partir do plano naquele instante. Escreva o consumidor tolerando fix ausente com fixWithheld: true — a alternativa é uma integração que quebra no dia em que o plano muda.

Enquanto a ronda não termina

O relatório só existe quando a ronda produziu resultado. Antes disso — e para uma ronda que falhou sem gerar nada — esta rota responde 404:

json
{ "error": "not_found", "message": "Relatório não disponível." }

Não é erro de integração; é o estado normal de uma ronda em andamento. Consulte GET /scans/:id para saber se já dá para pedir o relatório.


POST /scans

Dispara uma ronda.

  • Escopo: scans:write
  • Content-Type: application/json
CampoTipoObrigatórioDescrição
targetstringsimURL completa do alvo, começando com http:// ou https://.
authorizedbooleansimPrecisa ser exatamente true. Ver abaixo.
aibooleannão (padrão true)Roda a análise com IA. Consome uma análise da cota do mês.
bash
curl -X POST https://api.guarita.dev/v1/public/scans \
  -H "Authorization: Bearer $GUARITA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target": "https://app.exemplo.com.br",
    "authorized": true,
    "ai": true
  }'
json
{
  "id": "scan_e11a03d9",
  "status": "running"
}

Esta rota dispara a ronda padrão: sem testes autenticados, sem modo agressivo, sem pré-diagnóstico LGPD. Essas opções são intrusivas ou exigem credenciais, e ficam no painel, onde há uma pessoa confirmando cada uma.

Por que authorized: true é obrigatório

authorized é uma declaração sua de que você tem permissão para escanear aquele alvo. Não é um flag de configuração e não tem valor padrão: sem ele, a resposta é 400.

A razão é direta. Escanear o app de um terceiro sem autorização é o risco legal do produto, e a API não pode ser o atalho por onde ele entra. A mesma confirmação existe na tela, como checkbox obrigatório; a chave muda quem chama, nunca o que é permitido. Cada ronda grava a declaração num log append-only, com data, versão dos termos, IP e user-agent da chamada — evidência que sobrevive à poda de histórico.

Isso tem uma consequência prática no seu código: não mande authorized: true embutido num wrapper genérico que qualquer job da empresa pode chamar com qualquer URL. Deixe a declaração perto de quem escolheu o alvo.

Alvos óbvios de terceiros (órgãos públicos, bancos, grandes plataformas) são recusados com 403 target_blocked mesmo com a declaração — a lista não é negociável.

Por que 202, e como acompanhar

Uma ronda leva de dezenas de segundos a minutos: ela busca o app, roda dezenas de módulos e, quando há IA, ainda passa pela análise. Segurar a conexão HTTP durante tudo isso daria timeout no cliente antes de dar resposta.

Então o 202 Accepted significa "aceitei e enfileirei", não "terminei". O corpo traz o id e o status inicial (running). O resultado você busca depois, em GET /scans/:id:

js
const BASE = "https://api.guarita.dev/v1/public";
const auth = { Authorization: `Bearer ${process.env.GUARITA_API_KEY}` };

async function rondar(target) {
  // 1. dispara
  const criada = await fetch(`${BASE}/scans`, {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ target, authorized: true, ai: true }),
  });
  if (criada.status !== 202) {
    throw new Error(`Guarita recusou a ronda: ${criada.status} ${await criada.text()}`);
  }
  const { id } = await criada.json();

  // 2. acompanha. Intervalo de 10s: a ronda leva minutos, então polling mais
  //    agressivo só gasta requisição sem antecipar nada.
  const limite = Date.now() + 35 * 60 * 1000;
  for (;;) {
    if (Date.now() > limite) throw new Error(`Ronda ${id} não terminou a tempo.`);
    await new Promise((r) => setTimeout(r, 10_000));

    const res = await fetch(`${BASE}/scans/${id}`, { headers: auth });
    const ronda = await res.json();
    if (ronda.status === "done") return ronda;
    if (ronda.status === "failed" || ronda.status === "canceled") {
      throw new Error(`Ronda ${id} terminou como ${ronda.status}.`);
    }
  }
}

// 3. decide. `blocked` significa que a Guarita não viu o app — a nota não vale.
const ronda = await rondar("https://app.exemplo.com.br");
if (ronda.summary.blocked) {
  console.warn("Ronda bloqueada:", ronda.summary.blockedReason);
} else if (ronda.summary.criticalCount > 0) {
  process.exit(1);
}

Se a sua ferramenta prefere ser avisada a perguntar, existe o webhook scan.completed: a Guarita chama a sua URL quando a ronda termina, com assinatura HMAC. Polling e webhook resolvem o mesmo problema — escolha um.

ai: true exige app verificado

Com ai: true (o padrão), a ronda só roda em app cuja propriedade foi comprovada. Sem isso, a resposta é 403 domain_unverified, com o hostname no corpo:

json
{
  "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"
}

A verificação é um fato sobre o domínio e não expira: você faz uma vez, no painel, e a integração para de esbarrar nisso. Enquanto ela não acontece, ai: false roda a ronda básica, que não consome cota de IA e não exige verificação — só não devolve a análise escrita nem o plano de correção priorizado.

Cota de IA é reservada no momento do enfileiramento, de forma idempotente por ronda. Ronda que falha libera a reserva: ronda falha não consome cota.


Códigos de erro

Todo erro responde JSON com error (código estável, para a sua lógica) e message (texto em português, para o log de quem vai ler). Alguns carregam campos extras, listados abaixo.

HTTPerrorOndeO que significa
400invalid_targetPOST /scanstarget ausente ou não é URL completa (http:// ou https://).
400authorization_requiredPOST /scansFaltou authorized: true.
400bad_requestqualquerCorpo malformado (JSON inválido, por exemplo).
401unauthorizedqualquerChave ausente, inválida, revogada — ou o plano da conta deixou de incluir a API. Vem com WWW-Authenticate.
402quota_exceededPOST /scansCota de análises com IA do período esgotada. Extras: plan, limit, used, overagePrice, upgradeTo.
402domain_limitPOST /scansLimite de apps do plano atingido ao escanear um host novo. Extras: limit, used, upgradeTo.
403insufficient_scopequalquerA chave é válida, mas não tem o escopo da rota. Extra: required.
403target_blockedPOST /scansAlvo na blocklist (órgão público, banco, grande plataforma), ou identificador interno de integração.
403domain_unverifiedPOST /scansai: true num app sem propriedade comprovada. Extra: hostname.
404not_foundGET /scans/:id, GET /scans/:id/reportRonda inexistente, de outra conta, ou sem relatório ainda.
409domain_provider_conflictPOST /scansO hostname já é gerenciado por uma integração (Vercel/Lovable). Extra: provider.
429rate_limitedPOST /scansMuitas rondas em pouco tempo. Extra: retryAfterSec, e o header Retry-After.
500internalqualquerErro nosso. Vale repetir a chamada.

Exemplo de 403 por escopo:

json
{
  "error": "insufficient_scope",
  "message": "Esta chave não tem a permissão \"scans:write\".",
  "required": "scans:write"
}

Exemplo de 402 por cota:

json
{
  "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"
}

Duas distinções que valem programar direito:

401 não é 403. 401 é "não sei quem você é" — chave errada, revogada ou plano perdido. 403 é "sei quem você é, e isso não pode". Tratar os dois como o mesmo erro faz alguém rotacionar uma chave que estava perfeita atrás de um problema de permissão que a rotação não resolve.

402 não é bug. Cota e limite de apps são estados de conta, não falhas. No CI, o tratamento certo é avisar quem cuida da assinatura, não repetir a chamada — repetir só gasta requisição contra uma porta que vai continuar fechada até alguém mudar o plano.

Em 429, respeite o Retry-After. O limite existe para manter a ronda básica gratuita funcionando para todo mundo, e insistir antes da hora só renova a espera.