Rondas
GET /scans, POST /scans (com todas as opções da ronda), GET /scans/{id} e POST /scans/{id}/cancel.
Histórico de rondas
/v1/public/scansescopo: scans:readAs rondas da conta, da mais recente pra mais antiga, paginadas por cursor. risk_score + critical_count são o par que uma automação usa pra decidir se derruba o build; risk_label vai junto porque o número sozinho não é acionável.
O histórico respeita a retenção do plano (Pro: 365 dias · Business: sem limite). Rondas mais antigas não estão "na próxima página" — não existem mais.
next_before vem preenchido sempre que a página veio CHEIA — inclusive quando o total é múltiplo exato do limit (a próxima chamada devolve data: [] e next_before: null). Pare o laço em next_before === null, não em data.length < limit.Parâmetros de consulta
| Parâmetro | Descrição |
|---|---|
limitintegerpadrão: 20 | Rondas por página. Teto 50 (valores acima são reduzidos a 50; ausente, inválido ou ≤ 0 cai no padrão). |
beforestring | Cursor: só rondas criadas ANTES deste instante (exclusivo). Use o next_before da página anterior. Data não-ISO responde 400 invalid_cursor. |
Requisição
curl https://api.guarita.dev/v1/public/scans?limit=20 \
-H "Authorization: Bearer $GUARITA_API_KEY"Resposta 200
Uma página do histórico. Objeto ScanList.
{
"data": [
{
"id": "scan_7b3e9a12",
"hostname": "app.exemplo.com.br",
"status": "done",
"created_at": "2026-08-27T14:02:56.902Z",
"finished_at": "2026-08-27T14:09:24.106Z",
"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:19:42.204Z",
"risk_score": 34,
"risk_label": "MODERATE_RISK",
"critical_count": 0,
"blocked": false
}
],
"next_before": "2026-08-21T11:15:03.517Z"
}Campos da resposta
| Campo | Descrição |
|---|---|
dataScanListItem[]sempre | As rondas desta página. |
next_beforestring | nullsempre | Cursor da próxima página (o created_at do último item). null = acabou. |
Erros
- 400invalid_cursor
beforenão é uma data ISO 8601. - 401unauthorizedChave ausente, fora do formato
Bearer gsk_…, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o headerWWW-Authenticate. - 403insufficient_scopeA chave é válida, mas não tem o escopo da rota.
- 400bad_requestCorpo malformado (JSON inválido). O status acompanha o motivo:
413com corpo acima de 1 MB,415comContent-Typeque não seja JSON — o códigoerrorcontinuabad_request. - 500internalErro nosso.
Dispara uma ronda
/v1/public/scansescopo: scans:writeEnfileira uma ronda e responde 202 na hora com o id. A ronda não é instantânea (busca o app, roda dezenas de verificações e, com IA, ainda analisa), então o resultado você busca depois em GET /scans/{id} — ou recebe pelo webhook scan.completed.
Só target é obrigatório: sem mais nada, é a ronda padrão do painel (IA e infra ligadas, o resto desligado). As demais opções são as MESMAS da tela — testes autenticados, modo agressivo, LGPD e escopo extra de infra — com os mesmos padrões e as mesmas travas.
Cota, limite de apps, plano, verificação e autorização são decididos pelo MESMO caso de uso do painel: a chave muda quem chama, nunca o que é permitido. Ordem das travas: uso justo (429) → lista de bloqueio (403) → recurso do plano (403 feature_locked) → propriedade do app (403 domain_unverified) → confirmação intrusiva (400) → cota de IA (402) → limite de apps (402).
scans:write, gravada com data, versão dos Termos, IP e user-agent. Por isso o corpo desta rota não pede nenhuma confirmação de autorização — a única exceção é o modo agressivo, logo abaixo.aggressive: true mexe no app de verdade (cria, altera e apaga dados de teste) e por isso exige, ALÉM da declaração da chave, authorized_intrusive: true em cada ronda — sem ele, 400 intrusive_unauthorized. Não embuta essa confirmação num wrapper genérico que qualquer job chama com qualquer URL: deixe-a perto de quem escolheu o alvo.ai, authenticated e aggressive exigem app com prova de propriedade VIGENTE; sem ela, 403 domain_unverified. Trate como estado esperado: refaça com ai: false (ronda básica, sem cota, sem verificação), avise quem cuida da integração e deixe o build seguir.authenticated, aggressive e lgpd dependem de recurso do plano; sem ele, 403 feature_locked com feature e upgradeTo. Hoje todo plano com API (Pro e Business) inclui os três, mas a trava é avaliada a cada chamada pelo plano daquele instante — trate o código no cliente.Corpo da requisição
JSON, com Content-Type: application/json.
| Parâmetro | Descrição |
|---|---|
targetstringobrigatório | URL completa do alvo, começando com http:// ou https://. |
aibooleanpadrão: true | Análise com IA: explica cada achado e entrega o conserto pronto. Consome 1 análise da cota do mês e exige app verificado. |
infrabooleanpadrão: true | Descoberta e sondagem da infraestrutura por trás do app (Supabase, Firebase, clouds). Só leitura. |
authenticatedbooleanpadrão: false | Testes autenticados: a Guarita cria um usuário de teste no seu app e checa, por dentro, se dá pra ver dados de outras pessoas. Exige plano com o recurso (Pro+) e app verificado. Pode disparar o e-mail de boas-vindas do seu app. |
aggressivebooleanpadrão: false | Modo agressivo: confirma as brechas explorando de verdade — cria, altera e apaga dados de teste (e limpa depois). Liga sozinho authenticated e infra. Exige plano com o recurso (Pro+), app verificado e authorized_intrusive: true. Prefira um ambiente de teste. |
authorized_intrusivebooleanpadrão: false | Só é lido com aggressive: true: a sua confirmação, POR RONDA, de que tem autorização expressa pra testes intrusivos neste alvo — os Termos (§2) exigem. Sem ela, 400 intrusive_unauthorized. |
lgpdbooleanpadrão: false | Pré-diagnóstico LGPD: consentimento de cookies, rastreadores, política de privacidade, dado pessoal exposto e dados saindo do país. Exige plano com o recurso (Pro+). São sinais técnicos — não é parecer jurídico. |
infra_scopestring[] | Outros endereços SEUS que o app usa (api., cdn.…), pra entrarem na sondagem de infra. Array de hostnames ou uma string separada por vírgula. Só o que é seu ou que você tem permissão de testar. |
Requisição
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",
"ai": true
}'Resposta 202
Ronda aceita e enfileirada. Objeto ScanCreated.
{
"id": "scan_e11a03d9",
"status": "running"
}Campos da resposta
| Campo | Descrição |
|---|---|
idstringsempre | Id da ronda nova. Use em GET /scans/{id}. |
statusstringsempre | Estado inicial (running).queuedrunninganalyzingdonefailedcanceled |
Erros
- 400invalid_target
targetausente ou não é URL completa (http://ouhttps://). - 400intrusive_unauthorized
aggressive: truesemauthorized_intrusive: true. O modo agressivo mexe no app de verdade, e a confirmação é por ronda — a declaração da chave não basta. - 402quota_exceededCota de análises com IA do mês esgotada (só com
ai: true). - 402domain_limitLimite de apps do plano atingido ao escanear um hostname NOVO.
- 403feature_lockedA ronda pediu um recurso que o plano da conta não inclui:
authenticatedouaggressive(authenticatedScan) oulgpd(lgpdScan). - 403target_blockedAlvo 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.
- 403domain_unverified
ai: true,authenticated: trueouaggressive: truenum app sem prova de propriedade VIGENTE — nunca verificado, ou atestação de provedor vencida/revogada. - 409domain_provider_conflictO hostname é de um projeto gerenciado por uma integração (Vercel ou Lovable).
- 429rate_limitedMuitas rondas em pouco tempo. O balde é por conta: 10 rondas de rajada, reabastecendo 1 por minuto.
- 401unauthorizedChave ausente, fora do formato
Bearer gsk_…, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o headerWWW-Authenticate. - 403insufficient_scopeA chave é válida, mas não tem o escopo da rota.
- 400bad_requestCorpo malformado (JSON inválido). O status acompanha o motivo:
413com corpo acima de 1 MB,415comContent-Typeque não seja JSON — o códigoerrorcontinuabad_request. - 500internalErro nosso.
Estado e resumo de uma ronda
/v1/public/scans/{id}escopo: scans:readO endpoint de polling depois de POST /scans. Enquanto a ronda roda, progress diz onde ela está (fase, verificações feitas/previstas, verificação atual) e summary é null; quando status vira done, progress vira null e summary traz score, faixa e contagem de críticos.
Um id de outra conta responde 404, igual a um id inexistente — a API não confirma a existência de rondas de terceiros.
summary.blocked: true, o número não representa o app — a Guarita não conseguiu enxergá-lo. Falhar o build por isso pune o time errado. Ronda bloqueada não consome cota de IA.Parâmetros de rota
| Parâmetro | Descrição |
|---|---|
idstringobrigatório | Id da ronda (scan_…), devolvido por POST /scans ou por GET /scans. |
Requisição
curl https://api.guarita.dev/v1/public/scans/scan_7b3e9a12 \
-H "Authorization: Bearer $GUARITA_API_KEY"Resposta 200
A ronda. Objeto Scan.
{
"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:09:24.106Z",
"progress": null,
"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:09:24.106Z",
"durationMs": 387096,
"blocked": false
}
}Campos da resposta
| Campo | Descrição |
|---|---|
idstringsempre | Id da ronda. |
hostnamestringsempre | Alvo normalizado — é por ele que a cota de apps é contada. |
targetstringsempre | URL exata que você mandou escanear. |
statusstringsempre | Estado atual.queuedrunninganalyzingdonefailedcanceled |
created_atstringsempre | Quando a ronda foi criada. |
finished_atstring | nullsempre | Quando terminou. null enquanto roda. |
progressScanProgress | nullsempre | Onde a ronda está enquanto roda — a mesma leitura da tela de espera. null quando não está rodando (ainda na fila, terminada, falha ou cancelada). |
summaryScanSummary | nullsempre | Resumo do resultado. null enquanto a ronda não produziu resultado (queued, running, analyzing) e numa ronda failed que morreu antes de gerar algo. |
Erros
- 404not_foundRonda inexistente, de outra conta, ou ainda sem relatório.
- 401unauthorizedChave ausente, fora do formato
Bearer gsk_…, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o headerWWW-Authenticate. - 403insufficient_scopeA chave é válida, mas não tem o escopo da rota.
- 400bad_requestCorpo malformado (JSON inválido). O status acompanha o motivo:
413com corpo acima de 1 MB,415comContent-Typeque não seja JSON — o códigoerrorcontinuabad_request. - 500internalErro nosso.
Cancela uma ronda em andamento
/v1/public/scans/{id}/cancelescopo: scans:writeInterrompe uma ronda que ainda está rodando (queued, running ou analyzing) e responde com o estado dela. É o botão "Cancelar" da tela de espera, pra quando o pipeline foi abortado ou a ronda saiu por engano.
Um id de outra conta responde 404, igual a um id inexistente.
done, failed ou canceled) volta como está, com 200 — cancelar duas vezes não quebra nada. Leia status na resposta em vez de assumir canceled.ai: true) é liberada. O cancelamento é cooperativo — o estado vira canceled na hora e a ronda para na próxima etapa, sem sobrescrever esse estado com done ou failed.Parâmetros de rota
| Parâmetro | Descrição |
|---|---|
idstringobrigatório | Id da ronda (scan_…), devolvido por POST /scans ou por GET /scans. |
Requisição
curl -X POST https://api.guarita.dev/v1/public/scans/scan_7b3e9a12/cancel \
-H "Authorization: Bearer $GUARITA_API_KEY"Resposta 200
O estado da ronda depois do pedido. Objeto ScanCanceled.
{
"id": "scan_7b3e9a12",
"status": "canceled",
"finished_at": "2026-09-02T12:00:00.000Z"
}Campos da resposta
| Campo | Descrição |
|---|---|
idstringsempre | Id da ronda. |
statusstringsempre | canceled quando a ronda foi interrompida agora. Se já tinha terminado, vem o estado final que ela tinha (done, failed ou canceled).queuedrunninganalyzingdonefailedcanceled |
finished_atstring | nullsempre | Quando a ronda parou. |
Erros
- 404not_foundRonda inexistente, de outra conta, ou ainda sem relatório.
- 401unauthorizedChave ausente, fora do formato
Bearer gsk_…, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o headerWWW-Authenticate. - 403insufficient_scopeA chave é válida, mas não tem o escopo da rota.
- 400bad_requestCorpo malformado (JSON inválido). O status acompanha o motivo:
413com corpo acima de 1 MB,415comContent-Typeque não seja JSON — o códigoerrorcontinuabad_request. - 500internalErro nosso.
As opções da ronda
Só target é obrigatório: a URL completa, com https://. O resto são as mesmas opções da tela, em snake_case. Booleanos aceitam true/false e as strings "true"/"false".
| Opção | O que liga | Exige | Padrão |
|---|---|---|---|
ai | A análise com IA: explica cada achado em português e escreve o conserto pronto pra colar na sua ferramenta de IA. Consome 1 análise da cota do mês. | App verificado | true |
infra | A descoberta de infra: mapeia Supabase, Firebase e clouds e sonda o que está exposto. Só leitura. | — | true |
authenticated | Os testes autenticados: a Guarita cria um usuário de teste no seu app e checa, por dentro, se dá pra ver dados de outras pessoas. Precisa de cadastro aberto. | Plano Pro ou Business · app verificado | false |
aggressive | O modo agressivo: confirma as brechas tentando explorá-las de verdade. Liga sozinho authenticated e infra. | Plano Pro ou Business · app verificado · authorized_intrusive: true | false |
authorized_intrusive | A sua confirmação, por ronda, de que o modo agressivo pode mexer no app (Termos §2). Só é lida com aggressive: true; sem ela, 400 intrusive_unauthorized. | — | false |
lgpd | O pré-diagnóstico LGPD: consentimento de cookies, rastreadores, política de privacidade, dado pessoal exposto e dados saindo do Brasil. Sinais técnicos — não é parecer jurídico. | Plano Pro ou Business | false |
infra_scope | Outros endereços seus (api., cdn.) no escopo de infra. Array de hostnames ou string separada por vírgula. Só o que é seu. | — | vazio |
{
"target": "https://staging.exemplo.com.br",
"ai": true,
"infra": true,
"authenticated": true,
"aggressive": true,
"authorized_intrusive": true,
"lgpd": true,
"infra_scope": [
"api.exemplo.com.br",
"cdn.exemplo.com.br"
]
}authorized_intrusive: true). Use no seu app ou em staging; no app de produção, avise o time antes.ai, authenticated e aggressive exigem app com prova de propriedade vigente — sem ela, 403 domain_unverified. Plano sem o recurso → 403 feature_locked, com feature e upgradeTo no corpo. A ordem das travas: uso justo (429) → lista de bloqueio → recurso do plano → propriedade do app → confirmação intrusiva → cota de IA → limite de apps.O corpo não pede nenhuma confirmação de autorização: ela mora na chave, feita uma vez ao criá-la — veja a declaração de autorização. A exceção é o modo agressivo, que confirma por ronda.
Disparar e acompanhar
Uma ronda não é instantânea e não tem duração fixa — depende do tamanho do app, da latência dele e de quanto o WAF do outro lado atrasa cada requisição. O que é fixo é o teto: 30 minutos, e dentro deles a ronda se autolimita e termina parcial em vez de morrer sem entregar nada. Por isso POST /scans responde 202 na hora e você acompanha por polling — ou pelo webhook scan.completed, que dispensa o laço.
Enquanto roda, GET /scans/{id} traz progress: a fase (running ou analyzing), quantas verificações já foram e o módulo da vez — a mesma leitura da tela de espera. Vem null quando a ronda não está rodando.
{
"id": "scan_7b3e9a12",
"hostname": "app.exemplo.com.br",
"target": "https://app.exemplo.com.br",
"status": "running",
"created_at": "2026-09-02T10:41:07.552Z",
"finished_at": null,
"progress": {
"phase": "running",
"done": 12,
"total": 40,
"module": "headers",
"with_ai": true,
"updated_at": "2026-09-02T10:42:00.000Z"
},
"summary": null
}const BASE = "https://api.guarita.dev/v1/public";
const auth = { Authorization: `Bearer ${process.env.GUARITA_API_KEY}` };
async function rondar(target) {
// 1. dispara (a declaração de autorização já está na chave)
const criada = await fetch(`${BASE}/scans`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ target, 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. 10 s entre consultas: mais rápido só gasta requisição.
// 35 min de teto = os 30 min máximos de uma ronda, com folga.
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.progress) {
const { phase, done, total } = ronda.progress;
console.log(`ronda ${id}: ${phase} ${done}/${total}`);
}
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` = 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);
}import os, time, requests
BASE = "https://api.guarita.dev/v1/public"
AUTH = {"Authorization": f"Bearer {os.environ['GUARITA_API_KEY']}"}
def rondar(target: str) -> dict:
# 1. dispara (a declaração de autorização já está na chave)
criada = requests.post(
f"{BASE}/scans",
headers=AUTH,
json={"target": target, "ai": True},
timeout=30,
)
if criada.status_code != 202:
raise RuntimeError(f"Guarita recusou a ronda: {criada.status_code} {criada.text}")
scan_id = criada.json()["id"]
# 2. acompanha (10 s entre consultas; 35 min de teto)
limite = time.time() + 35 * 60
while True:
if time.time() > limite:
raise RuntimeError(f"Ronda {scan_id} não terminou a tempo.")
time.sleep(10)
ronda = requests.get(f"{BASE}/scans/{scan_id}", headers=AUTH, timeout=30).json()
if ronda.get("progress"):
p = ronda["progress"]
print(f"ronda {scan_id}: {p['phase']} {p['done']}/{p['total']}")
if ronda["status"] == "done":
return ronda
if ronda["status"] in ("failed", "canceled"):
raise RuntimeError(f"Ronda {scan_id} terminou como {ronda['status']}.")
# 3. decide
ronda = rondar("https://app.exemplo.com.br")
if ronda["summary"].get("blocked"):
print("Ronda bloqueada:", ronda["summary"].get("blockedReason"))
elif ronda["summary"]["criticalCount"] > 0:
raise SystemExit(1)Pra desistir, POST /scans/{id}/cancel. É idempotente — ronda que já terminou volta como está — e libera a reserva de cota de IA. Ronda de outra conta responde 404, como em todo lugar.
// O job abortou? Cancele a ronda em vez de deixá-la rodando à toa.
const res = await fetch(`${BASE}/scans/${id}/cancel`, { method: "POST", headers: auth });
const { status, finished_at } = await res.json();
// "canceled" — ou o estado final, se a ronda já tinha terminado (a chamada é idempotente).x-request-id, se for sobre uma resposta.