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:
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.
| Escopo | O que permite | Endpoints |
|---|---|---|
scans:read | Ler rondas e relatórios | GET /me, GET /scans, GET /scans/:id, GET /scans/:id/report |
scans:write | Disparar novas rondas | POST /scans |
apps:read | Listar os apps monitorados | GET /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
curl https://api.guarita.dev/v1/public/me \
-H "Authorization: Bearer $GUARITA_API_KEY"{
"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
curl https://api.guarita.dev/v1/public/apps \
-H "Authorization: Bearer $GUARITA_API_KEY"{
"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
}
]
}| Campo | Tipo | Descrição | |
|---|---|---|---|
hostname | string | Hostname normalizado: minúsculo, sem www e sem porta. | |
created_at | string | Quando o app foi registrado na conta. | |
last_scan_at | string \ | null | Última ronda contra este app. null = nunca escaneado. |
verified | boolean | A 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âmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
limit | inteiro | 20 | Quantas 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. |
before | string ISO | — | Cursor: devolve só rondas criadas antes deste instante. |
curl "https://api.guarita.dev/v1/public/scans?limit=2" \
-H "Authorization: Bearer $GUARITA_API_KEY"{
"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"
}| Campo | Tipo | Descrição | |
|---|---|---|---|
id | string | Id da ronda (scan_…), usado nos endpoints de detalhe e relatório. | |
hostname | string | Alvo normalizado. | |
status | string | queued, running, analyzing, done, failed ou canceled. | |
created_at | string \ | null | Quando a ronda foi criada. É também o valor do cursor. |
finished_at | string \ | null | Quando terminou. null enquanto roda. |
risk_score | número | Exposição de 0 a 100 (quanto maior, pior). | |
risk_label | string | SECURE, LOW_RISK, MODERATE_RISK, HIGH_RISK ou CRITICAL_RISK. | |
critical_count | número | Quantos achados de severidade critical. | |
blocked | boolean | A 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:
curl "https://api.guarita.dev/v1/public/scans?limit=2&before=2026-08-21T11:15:03.517Z" \
-H "Authorization: Bearer $GUARITA_API_KEY"{
"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.
// 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— oscan_…devolvido na criação
curl https://api.guarita.dev/v1/public/scans/scan_7b3e9a12 \
-H "Authorization: Bearer $GUARITA_API_KEY"{
"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:
{
"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 ou403de 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
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):
{
"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[]):
| Campo | Tipo | Descrição |
|---|---|---|
id | string | Id do achado dentro desta ronda (ex.: SECRETS-001). Renumera entre rondas — para casar achados entre rondas use module + title. |
module | string | Verificação que produziu o achado (secrets, cors, headers, supabase…). |
severity | string | critical, high, medium, low ou info. |
cvss | número | Pontuação CVSS do achado. |
title | string | Título técnico. |
description | string | O que foi encontrado, em detalhe. |
evidence | string | A prova observada: requisição, resposta, trecho do bundle. |
recommendation | string | Recomendação curta (não é o conserto pronto). |
confidence | string | confirmed (provado por observação ativa), firm (detecção determinística) ou tentative (heurística que pede confirmação). Ausente ⇒ trate como firm. |
good | boolean | Achado positivo: uma boa prática comprovada, não um problema. |
links | string[] | Ids de achados relacionados (hoje, os elos de uma cadeia de ataque). |
friendly | objeto | Versão em linguagem de produto: headline, whatItIs, whyItMatters, urgency (now/soon/later). |
fix | objeto | O 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:
{
"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:
{ "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
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
target | string | sim | URL completa do alvo, começando com http:// ou https://. |
authorized | boolean | sim | Precisa ser exatamente true. Ver abaixo. |
ai | boolean | não (padrão true) | Roda a análise com IA. Consome uma análise da cota do mês. |
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
}'{
"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:
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:
{
"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.
| HTTP | error | Onde | O que significa |
|---|---|---|---|
| 400 | invalid_target | POST /scans | target ausente ou não é URL completa (http:// ou https://). |
| 400 | authorization_required | POST /scans | Faltou authorized: true. |
| 400 | bad_request | qualquer | Corpo malformado (JSON inválido, por exemplo). |
| 401 | unauthorized | qualquer | Chave ausente, inválida, revogada — ou o plano da conta deixou de incluir a API. Vem com WWW-Authenticate. |
| 402 | quota_exceeded | POST /scans | Cota de análises com IA do período esgotada. Extras: plan, limit, used, overagePrice, upgradeTo. |
| 402 | domain_limit | POST /scans | Limite de apps do plano atingido ao escanear um host novo. Extras: limit, used, upgradeTo. |
| 403 | insufficient_scope | qualquer | A chave é válida, mas não tem o escopo da rota. Extra: required. |
| 403 | target_blocked | POST /scans | Alvo na blocklist (órgão público, banco, grande plataforma), ou identificador interno de integração. |
| 403 | domain_unverified | POST /scans | ai: true num app sem propriedade comprovada. Extra: hostname. |
| 404 | not_found | GET /scans/:id, GET /scans/:id/report | Ronda inexistente, de outra conta, ou sem relatório ainda. |
| 409 | domain_provider_conflict | POST /scans | O hostname já é gerenciado por uma integração (Vercel/Lovable). Extra: provider. |
| 429 | rate_limited | POST /scans | Muitas rondas em pouco tempo. Extra: retryAfterSec, e o header Retry-After. |
| 500 | internal | qualquer | Erro nosso. Vale repetir a chamada. |
Exemplo de 403 por escopo:
{
"error": "insufficient_scope",
"message": "Esta chave não tem a permissão \"scans:write\".",
"required": "scans:write"
}Exemplo de 402 por cota:
{
"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.