Autenticação e permissões
Toda chamada à API pública é autenticada por uma chave de API (gsk_…), enviada no header Authorization. A sessão do painel não vale aqui, e isso é deliberado: uma sessão dura 7 dias, carrega a identidade completa da pessoa e morre quando ela troca a senha. Uma credencial de máquina precisa do contrário — viver o tempo que a integração viver, poder ser revogada sozinha (sem derrubar as outras) e fazer só um pedaço do que a pessoa poderia fazer.
Base de produção: https://api.guarita.dev. As rotas públicas ficam sob /v1/public.
O header
curl https://api.guarita.dev/v1/public/me \
-H "Authorization: Bearer gsk_SUA_CHAVE"{
"account_id": "acc_9f3a21c8",
"key_id": "key_3f2a91b7",
"scopes": ["scans:read", "apps:read"],
"plan": "pro"
}GET /v1/public/me é o ping autenticado: confirma que a chave funciona e mostra exatamente o que ela pode. É o primeiro request a fazer quando uma integração para de funcionar.
Sobre o formato da chave:
- O segredo é
gsk_(Guarita Secret Key) seguido de 32 bytes aleatórios em base64url. Base64url não tem+,/nem=, então a chave atravessa header, URL e variável de ambiente sem precisar de escape. - A API lê a chave apenas do header
Authorization, no formatoBearer <chave>. Não existe parâmetro de query nem cookie equivalente — chave em query string acaba em log de acesso, histórico de navegador e headerReferer, e essa porta não existe aqui. - O segredo em claro aparece uma única vez, na resposta da criação. Depois disso a conta guarda só o hash; o que você vê na listagem é o nome que você deu e um
hint(gsk_mais 8 caracteres), o bastante para reconhecer qual chave é qual na hora de revogar.
Guarde a chave como variável de ambiente (GUARITA_API_KEY), nunca no repositório.
Escopos: o que cada chave pode
Uma chave carrega uma lista fixa de escopos, escolhida na criação. São três:
| Escopo | O que libera | Rotas |
|---|---|---|
scans:read | Ler rondas e relatórios | GET /v1/public/me, GET /v1/public/scans, GET /v1/public/scans/{id}, GET /v1/public/scans/{id}/report |
scans:write | Disparar novas rondas | POST /v1/public/scans |
apps:read | Listar os apps monitorados | GET /v1/public/apps |
Repare que /me exige scans:read. Uma chave criada só com apps:read é válida, mas não consegue usar o próprio ping — nesse caso, teste com GET /v1/public/apps.
Por que escopo existe
Porque as rotas não custam a mesma coisa. Ler uma ronda é leitura de dado que já existe; POST /v1/public/scans inicia tráfego contra o seu alvo e consome cota de análise de IA da conta. Separar os dois significa que uma chave de leitura vazada não dispara ronda nem gasta cota — ela expõe relatórios (o que já é grave), mas não vira um gerador de tráfego e de fatura no seu nome.
Na prática: um passo de CI que só decide falhar o build a partir do risco da última ronda precisa de scans:read e nada mais. Dê scans:write apenas para a integração que realmente dispara ronda.
A superfície da API pública também é intencionalmente menor que a do painel: cobrança, gestão de time, dados fiscais e exclusão de conta não têm rota pública. Chave vazada não pode virar dano irreversível.
O escopo é fixado na criação e não é editável. Para trocar, crie uma chave nova com os escopos certos, troque a variável de ambiente e revogue a antiga.
401 e 403 não são a mesma coisa
- 401 — "não sei quem você é". A chave não chegou, não é válida, foi revogada, ou a conta não tem plano com API.
- 403 — "sei quem você é, e essa chave não pode isso". A chave é válida; falta o escopo da rota.
A distinção importa por um motivo bem concreto: se a API respondesse 401 para falta de permissão, a reação natural do time seria rotacionar uma chave que estava perfeitamente boa — trocando o segredo em todos os ambientes atrás de um problema que era de escopo. O contrário também é ruim: um 403 para uma chave revogada faria alguém procurar permissão onde o problema é credencial.
401 na prática
curl -i https://api.guarita.dev/v1/public/me{
"error": "unauthorized",
"message": "Chave de API ausente ou inválida. Envie `Authorization: Bearer gsk_...` — crie a sua em Configurações > Desenvolvedores."
}A resposta também traz o header www-authenticate: Bearer realm="guarita", error="invalid_token".
Esse mesmo corpo, idêntico, é a resposta para todos estes casos:
- header
Authorizationausente ou fora do formatoBearer …; - segredo sem o prefixo
gsk_; - chave que nunca existiu;
- chave revogada;
- conta cujo plano não inclui a API pública.
A API não diferencia os casos de propósito. Dizer "essa chave existe, mas foi revogada" ajudaria mais quem está sondando chaves do que quem esqueceu de atualizar uma variável de ambiente. Do seu lado, o roteiro diante de um 401 é sempre o mesmo: conferir a variável de ambiente, conferir o estado da chave no painel e conferir o plano da conta. Repetir a chamada em loop nunca resolve.
403 na prática
Uma chave com scans:read tentando disparar uma ronda:
curl -i -X POST https://api.guarita.dev/v1/public/scans \
-H "Authorization: Bearer gsk_CHAVE_SOMENTE_LEITURA" \
-H "Content-Type: application/json" \
-d '{"target":"https://app.suaempresa.com.br","authorized":true}'{
"error": "insufficient_scope",
"message": "Esta chave não tem a permissão \"scans:write\".",
"required": "scans:write"
}O campo required existe para ser lido por código, não só por humano: ele diz qual escopo emitir. Um 403 nunca se resolve com retry — a chave não ganha escopo com o tempo.
const res = await fetch("https://api.guarita.dev/v1/public/scans", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.GUARITA_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ target: "https://app.suaempresa.com.br", authorized: true }),
});
if (res.status === 401) {
// Credencial: chave ausente, inválida, revogada, ou conta sem plano com API.
throw new Error("Chave de API não aceita. Verifique GUARITA_API_KEY e o plano da conta.");
}
if (res.status === 403) {
const { required } = await res.json();
// Permissão: a chave é válida. Emita outra com o escopo indicado.
throw new Error(`Falta o escopo "${required}" nesta chave.`);
}E quando o recurso é de outra conta
Pedir uma ronda que pertence a outra conta responde 404 not_found, não 403. Um 403 aí confirmaria que aquele id existe — informação que quem tem uma chave sua não deveria conseguir extrair sobre contas alheias.
Revogação vale imediatamente
Revogar é a resposta a "vazou", e por isso precisa valer agora, não no próximo ciclo. Vale.
O motivo é técnico, e explica por que a chave não é um JWT. Um token auto-contido carrega dentro de si a identidade e a validade: para revogá-lo antes do vencimento você precisa de uma lista negra, que precisa ser consultada, que precisa de cache — e o cache é exatamente o que faz uma revogação demorar minutos. A chave da Guarita é o oposto: guardamos o SHA-256 do segredo e, a cada request, a autenticação resolve esse hash contra o banco com o filtro de revogação dentro da própria consulta (revoked_at is null). Não existe caminho em que uma chave revogada seja aceita porque "ainda estava em cache" — a requisição seguinte já falha com 401.
O custo dessa escolha é um lookup por request. É uma troca consciente: você paga uma consulta indexada para ganhar a garantia de que revogar encerra o acesso na hora.
Outros dois detalhes do ciclo de vida:
- Revogar não apaga a chave. A linha vira histórico, com a data da revogação, e continua aparecendo na listagem. Isso preserva o registro de que aquela credencial existiu, e nenhum segredo revogado volta a ser aceito.
- Cada uso carimba a data do último uso da chave. Serve para você encontrar a chave esquecida — a que ninguém usa há meses — antes de revogar. É um carimbo best-effort, não um log de auditoria: se ele falhar, o request legítimo passa mesmo assim.
Teto de 10 chaves ativas
Uma conta pode ter no máximo 10 chaves ativas. Ao tentar criar a décima primeira:
{
"error": "api_key_limit",
"message": "Você já tem 10 chaves ativas. Revogue uma que não usa mais antes de criar outra — chave esquecida é chave que ninguém vigia."
}A resposta é 409, e não um erro de plano: não falta assinatura, sobra chave.
O teto existe por dois motivos. O primeiro é limitar o estrago: cada chave viva é uma superfície independente de vazamento, e uma conta com 80 chaves não tem como saber o que cada uma faz. O segundo é manter a lista utilizável — quando a lista cabe na tela, a pessoa revoga a chave certa; quando não cabe, ela revoga todas "por segurança" e derruba a produção junto.
Chaves revogadas não ocupam espaço no teto: só as ativas contam. O limite é verificado e a chave é inserida num único comando no banco, então duas abas abertas ao mesmo tempo não conseguem furar o teto juntas.
Na criação, o nome é obrigatório (2 a 40 caracteres) e ao menos um escopo precisa ser escolhido; violar qualquer um dos dois responde 400 invalid_api_key. O nome não é enfeite: é por ele e pelo hint que você identifica a chave meses depois.
Quem pode emitir e revogar
Gestão de chaves é operação de dono da conta. Membros de um time não criam nem revogam chaves — uma chave dá acesso programático à conta inteira e um webhook entrega achados de segurança dos apps, então isso pertence à mesma família de cobrança e dados fiscais, não à de "coisa que membro do time mexe".
A chave também é resolvida direto pela conta que a emitiu, sem passar pela resolução de workspace do painel. É o que impede uma chave criada antes de um convite de mudar de alcance sozinha quando alguém entra no time depois.
O que acontece se a conta cair de plano
A API pública faz parte da capacidade webhookApi, presente nos planos Pro e Business. Free e Starter não têm.
Se a assinatura cair para um plano sem API — downgrade, cancelamento ou renovação que não veio:
- As chaves não são revogadas nem apagadas. Elas continuam na conta, com os mesmos escopos e os mesmos segredos.
- Toda chamada à API pública passa a responder 401, com o mesmo corpo de sempre. O plano é reavaliado a cada request, no caminho de autenticação — não só no momento da emissão. Isso é o que faz o bloqueio valer no mesmo instante, sem depender de uma rotina varrendo o banco para revogar chaves em massa.
- Criar e revogar chaves pelo painel respondem `403 feature_locked`, com o plano sugerido:
{
"error": "feature_locked",
"message": "A API pública não está incluído no seu plano.",
"feature": "webhookApi",
"upgradeTo": "pro"
}- Voltando para um plano com API, as mesmas chaves voltam a funcionar. Não é preciso reemitir nada nem mexer nas variáveis de ambiente do CI.
Duas consequências práticas. Primeira: um 401 repentino em uma integração que estava estável há meses, sem ninguém ter tocado na chave, geralmente é cobrança, não credencial — confira o plano antes de rotacionar segredo. O campo plan de GET /v1/public/me responde isso em um request. Segunda: enquanto a conta está sem plano, a operação de revogar também fica bloqueada; na prática o acesso já está encerrado (nenhuma chave autentica), e a revogação formal fica disponível de novo assim que o plano voltar.
Resumo dos códigos
| Código | error | O que significa | O que fazer |
|---|---|---|---|
| 400 | invalid_api_key | Nome fora de 2 a 40 caracteres, ou nenhum escopo escolhido | Corrigir o corpo da criação |
| 401 | unauthorized | Chave ausente, inválida, revogada, ou conta sem plano com API | Conferir variável de ambiente, estado da chave e plano |
| 403 | insufficient_scope | Chave válida, sem o escopo da rota (veja required) | Emitir uma chave com o escopo indicado |
| 403 | feature_locked | O plano da conta não inclui a API pública | Assinar o plano indicado em upgradeTo |
| 404 | not_found | Recurso inexistente ou de outra conta | Conferir o id da ronda |
| 409 | api_key_limit | Já existem 10 chaves ativas | Revogar uma chave sem uso antes de criar outra |
Todo erro segue a mesma forma: error (código estável, para código ler) e message (texto em português, para humano ler), mais os campos extras da tabela quando existirem. Trate sempre pelo error, nunca pelo texto da mensagem.