← Documentação da API

Webhooks

Um webhook de saída inverte quem faz a chamada. Nos capítulos anteriores é a sua aplicação que chama https://api.guarita.dev com uma chave gsk_…. Aqui é a Guarita que faz um POST na SUA URL quando algo acontece na conta: uma ronda terminou, uma brecha nova apareceu.

Isso é o oposto do webhook de deploy (POST /v1/hooks/deploy), que já existia: nele o seu CI chama a Guarita para pedir uma ronda a cada deploy. O webhook de saída é o caminho de volta — é o que permite abrir um card no Jira, mandar mensagem no Slack ou falhar um pipeline sem ficar consultando GET /v1/public/scans de minuto em minuto.

Webhooks de saída fazem parte dos planos Pro e Business, junto com a API pública. A verificação de plano acontece no momento do envio: se a conta cair para um plano sem o recurso, os destinos continuam cadastrados e as entregas param.

Cadastrar um destino

Os destinos são gerenciados no painel, em Configurações → Desenvolvedores. Só quem é dono da conta pode mexer ali: um destino de webhook recebe os achados de segurança dos seus apps, então ele fica na mesma família de cobrança e dados fiscais, não na de "coisa que qualquer membro do time altera".

O cadastro pede duas coisas: a URL do destino e quais eventos ela recebe. Ao salvar, a tela mostra o segredo de assinatura (gwh_…) uma única vez e não o exibe de novo. Copie na hora e guarde no gerenciador de segredos da sua aplicação. Se perder, remova o destino e cadastre outro — o novo destino vem com um segredo novo.

Regras aplicadas no cadastro, todas com mensagem explícita na tela:

  • A URL precisa ser https:// completa.
  • O host precisa ser público (veja a seção sobre endereços internos).
  • Ao menos um evento marcado.
  • A mesma URL não pode ser cadastrada duas vezes na conta.
  • Máximo de 5 destinos por conta.

A lista de destinos mostra o resultado da última tentativa (status HTTP e erro). É o primeiro lugar para olhar quando a pergunta é "por que não está chegando?".

Eventos disponíveis

EventoSignificadoQuando sai
scan.completedUma ronda terminouToda ronda que passa pela fila de análise e é gravada como done
scan.failedUma ronda falhouA ronda esgotou as tentativas ou estourou o tempo sem produzir resultado
finding.openedUma ronda encontrou brecha nova (comparada à anterior)Ronda de plantão que abriu achado que não existia no baseline

Dois detalhes que evitam integração baseada em suposição:

Nem toda ronda emite `scan.completed`. O evento sai do worker que processa a fila de análise — ou seja, das rondas disparadas no painel, pela API (POST /v1/public/scans) e pelas integrações. As rondas que rodam por dentro, sem passar pela fila, não emitem esse evento: o plantão agendado e o scan-on-deploy (/v1/hooks/deploy). O plantão tem evento próprio, o finding.opened.

`scan.failed` cobre os dois finais ruins. Ele sai quando a ronda estoura o tempo sem produzir resultado e quando a fila desiste depois de todas as tentativas. Assine junto com scan.completed: só um dos dois chega para cada ronda, e tratar apenas o sucesso faz "ronda que não rodou" virar silêncio — que é indistinguível de "não encontrei nada".

O campo reason traz a causa em texto. Para consultar o estado a qualquer momento, use GET /v1/public/scans/{id} e olhe status, que pode ser queued, running, analyzing, done, failed ou canceled.

`finding.opened` não sai de ronda inconclusiva. Se a ronda foi bloqueada (WAF, desafio anti-bot) ou ficou parcial por tempo, o retrato está incompleto: comparar ele com uma ronda completa inventaria "achados novos" que na verdade são o que não deu para ver. Nesses casos nenhum evento é emitido — melhor não avisar do que disparar automação com diferença falsa. Por isso blocked e incomplete vêm sempre false nesse payload.

O finding.opened também tem uma regra que vale conhecer: ele só sai quando a ronda abriu pelo menos um achado novo em relação ao último scan concluído daquele app. Ronda limpa não gera evento — webhook que dispara sempre vira ruído e acaba desligado. E ele é independente do limiar de alerta por e-mail: o limiar é preferência de quem lê caixa de entrada, não contrato de integração.

O envelope

Todo evento chega no corpo do POST com a mesma estrutura de quatro campos:

CampoTipoO que é
idstringId do EVENTO (evt_ + UUID). Estável entre reentregas — é por ele que se deduplica
eventstringUm dos eventos da tabela acima
createdAtstringQuando o evento aconteceu, ISO 8601 em UTC
dataobjetoCarga específica do evento

O mesmo id vai para todos os destinos inscritos naquele evento. Se você cadastrou dois destinos que caem no mesmo serviço, ele verá o mesmo id duas vezes — o que não é reentrega, e sim duas assinaturas do mesmo aviso.

Exemplo: scan.completed

json
{
  "id": "evt_9f1c2a54-4c1e-4a0c-9a1f-7b6a2f0d8e33",
  "event": "scan.completed",
  "createdAt": "2026-08-29T14:22:07.481Z",
  "data": {
    "scan_id": "scan_5e2a91c4",
    "hostname": "app.suaempresa.com.br",
    "target": "https://app.suaempresa.com.br",
    "risk_score": 41,
    "risk_label": "MODERATE_RISK",
    "critical_count": 1,
    "blocked": false,
    "report_url": "https://guarita.dev/scans/scan_5e2a91c4"
  }
}
  • risk_score é a exposição de 0 a 100 — quanto MAIOR, pior.
  • risk_label é a faixa desse número: SECURE, LOW_RISK, MODERATE_RISK, HIGH_RISK ou CRITICAL_RISK.
  • critical_count conta os achados de severidade critical. O par score + críticos é o que costuma decidir se um pipeline falha.
  • blocked é true quando a ronda não conseguiu ver o app (bloqueio de conexão ou desafio de WAF/anti-bot). Nesse caso o score não representa o app: trate como inconclusivo, não como "seguro".
  • report_url aponta para o relatório no painel.

O relatório completo não vem no webhook. Use o scan_id e chame GET /v1/public/scans/{id}/report com a sua chave — assim a carga do POST continua pequena e os dados sensíveis saem por uma porta autenticada por você.

Exemplo: finding.opened

json
{
  "id": "evt_2c7d0b18-6f4a-4b7e-9c31-0a5d8e12b774",
  "event": "finding.opened",
  "createdAt": "2026-08-29T03:11:52.094Z",
  "data": {
    "scan_id": "scan_a17b3f90",
    "hostname": "app.suaempresa.com.br",
    "verdict": "breach",
    "risk_score": 58,
    "previous_risk_score": 41,
    "opened": [
      "Chave service_role do Supabase exposta no bundle",
      "Content-Security-Policy ausente"
    ],
    "resolved": [
      "Strict-Transport-Security (HSTS) ausente"
    ]
  }
}
  • verdict é o veredito da ronda de plantão: breach (achado novo grave, ou salto de 15 pontos ou mais na exposição), warning (piorou, sem gravidade) ou clean.
  • previous_risk_score é o score do baseline, ou null quando não havia ronda anterior.
  • opened e resolved são TÍTULOS de achados, não ids. O título é a chave de identidade usada na comparação entre rondas.

Um caso que surpreende quem integra: na PRIMEIRA ronda de um app não existe baseline, e todos os achados contam como novos. O evento sai com a lista cheia e verdict igual a clean, porque essa ronda estabelece a base em vez de declarar piora. Se o seu tratamento abre chamado para cada item de opened, considere ignorar eventos com previous_risk_score nulo.

Os cabeçalhos enviados

A requisição é sempre um POST na URL cadastrada, com estes cabeçalhos:

CabeçalhoExemploPara que serve
content-typeapplication/jsonO corpo é JSON em UTF-8
user-agentGuarita-Webhook/1Identifica a origem em log e regra de WAF. Não é autenticação
x-guarita-signaturet=1788013327,v1=8f3c…HMAC SHA-256 do conteúdo <t>.<corpo cru>, com o segredo do destino
x-guarita-deliverydlv_5a1e0c93-…Id desta entrega: uma por destino, por evento. Repete nas retentativas

Só o x-guarita-signature prova que a chamada é nossa. O user-agent é falsificável por qualquer um, e a URL do destino pode vazar em log; sem conferir a assinatura, seu endpoint aceita qualquer POST que alguém montar. A conferência tem capítulo próprio: verificar a assinatura. Duas regras que valem repetir aqui: assine sobre os BYTES CRUS do corpo (antes de qualquer JSON.parse, que reordena e reformata) e compare com função de tempo constante.

Para exercitar o seu handler antes de ligar o destino de verdade, uma entrega tem esta forma na linha de comando:

bash
curl -i -X POST https://api.suaempresa.com.br/guarita \
  -H 'content-type: application/json' \
  -H 'user-agent: Guarita-Webhook/1' \
  -H 'x-guarita-signature: t=1788013327,v1=SUA_ASSINATURA_DE_TESTE' \
  -H 'x-guarita-delivery: dlv_5a1e0c93-2b77-4a10-9e6d-1f2c34ab56de' \
  -d '{"id":"evt_teste","event":"scan.completed","createdAt":"2026-08-29T14:22:07.481Z","data":{"scan_id":"scan_5e2a91c4","hostname":"app.suaempresa.com.br","risk_score":41,"risk_label":"MODERATE_RISK","critical_count":1,"blocked":false}}'

HTTPS é obrigatório

O cadastro recusa qualquer URL que não comece com https://. O motivo é o conteúdo: o corpo carrega os achados de segurança do seu app — quais brechas existem, em quais endereços. Em HTTP isso viajaria legível por todo intermediário no caminho, o que entrega a um atacante um mapa pronto do que explorar, com a nossa colaboração. TLS também é o que garante que o destino é mesmo quem você cadastrou, e não alguém que sequestrou a rota.

Endereços internos são bloqueados

A URL vem de você e a chamada sai da NOSSA rede. Se aceitássemos qualquer endereço, o cadastro viraria um SSRF servido de bandeja: alguém apontaria um destino para http://169.254.169.254/, para um serviço interno do nosso cluster ou para localhost, e usaria a Guarita como proxy para dentro da infraestrutura. É a mesma guarda que impede a ronda de escanear IP interno — reaproveitada de propósito, para não existirem duas noções de "endereço seguro" divergindo com o tempo.

Na hora de cadastrar, resolvemos o hostname e exigimos que TODOS os endereços retornados (IPv4 e IPv6) sejam públicos. São recusados, entre outros: loopback (127.0.0.0/8, ::1), redes privadas (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), CGNAT (100.64.0.0/10), link-local (169.254.0.0/16, que inclui o endpoint de metadados de nuvem), multicast e faixas reservadas, além dos equivalentes IPv6 (ULA fc00::/7, link-local fe80::/10) e dos mecanismos de transição que poderiam encapsular um IPv4 privado (6to4, Teredo, NAT64).

Consequência prática no dia a dia: não dá para apontar um webhook para a sua máquina. Para desenvolver, use um túnel que exponha uma URL HTTPS pública e cadastre essa URL, removendo o destino quando terminar.

Retentativas

A primeira tentativa sai junto com o evento, sem esperar nenhum ciclo de agendador — um aviso que só chegasse no minuto seguinte não serviria para disparar automação. Cada tentativa tem 10 segundos de timeout: o destino é sistema de terceiro e não pode segurar o nosso worker.

Se a tentativa falhar, a entrega volta para a fila com espera exponencial. São no máximo 6 tentativas:

TentativaEspera desde a anteriorAcumulado desde o evento
1imediata, junto com o evento0
215 segundos~15 s
330 segundos~45 s
41 minuto~1 min 45 s
52 minutos~3 min 45 s
64 minutos~7 min 45 s

Depois da sexta falha, desistimos daquela entrega — não há fila morta para reenviar depois. O motivo da espera crescer é que a causa mais comum de falha é indisponibilidade temporária, e insistir de segundo em segundo atrapalha justamente quem está tentando voltar.

Os reenvios são processados por um ciclo que roda a cada 30 segundos, então cada retentativa pode acontecer até meio minuto depois do horário da tabela; a janela total fica em torno de 10 minutos no pior caso.

O que conta como falha depende da sua resposta:

Resposta do destinoTratamento
2xxEntrega concluída. Não lemos o corpo da resposta
4xx, exceto 429Definitivo: paramos na hora, sem retentativa
429Temporário: entra na fila de retentativa
5xxTemporário: entra na fila de retentativa
Timeout, DNS, TLS, conexão recusadaTemporário: entra na fila de retentativa

A separação entre 4xx e 5xx não é burocracia: reenviar seis vezes um corpo que o destino recusou por considerá-lo inválido só gasta tentativa e atrasa o resto da fila. Se o seu endpoint responde 4xx por um erro seu (rota errada, autenticação sua mal configurada), o evento é perdido — vale monitorar a coluna de último status no painel.

Redirecionamentos são seguidos pelo cliente HTTP e vale o status final, mas cadastre direto a URL definitiva: cada salto consome parte dos 10 segundos.

Se você remover o destino enquanto houver entrega pendente, ela é encerrada com o erro "destino removido" em vez de seguir tentando contra algo que não existe mais.

Responder rápido com 2xx é responsabilidade do destino

Os 10 segundos valem para a resposta inteira, o que muda como o handler deve ser escrito: receba, persista, responda, processe depois. Um endpoint que gera PDF, chama três APIs e só então responde vai estourar o timeout, ser contado como falha e receber o mesmo evento de novo — agora com o trabalho anterior possivelmente concluído pela metade.

js
import express from "express";

const app = express();

// express.raw preserva os BYTES do corpo. A assinatura cobre o corpo cru:
// JSON.parse seguido de JSON.stringify muda a ordem das chaves e invalida a conferência.
app.post("/guarita", express.raw({ type: "application/json" }), async (req, res) => {
  if (!assinaturaValida(req.headers["x-guarita-signature"], req.body)) {
    // 4xx é definitivo para nós: não haverá retentativa. É o que se quer aqui.
    return res.status(401).send();
  }

  const evento = JSON.parse(req.body.toString("utf8"));

  // Gravar é rápido; processar não é. `gravarSeAusente` retorna false quando o id
  // já existe — é a deduplicação e a fila local no mesmo passo.
  const novo = await gravarSeAusente(evento.id, evento);

  res.status(204).send(); // resposta antes do trabalho pesado

  if (novo) {
    processar(evento).catch((err) => console.error("[guarita] falha ao processar", err));
  }
});

app.listen(3000);

Deduplicação pelo id do evento

Seu handler precisa ser idempotente. A mesma entrega pode chegar mais de uma vez, e não por bug: se você processar o evento e a sua resposta se perder — timeout de 10 segundos estourado por meio segundo, conexão derrubada, 502 do seu proxy depois que a aplicação já gravou —, do nosso lado aquilo é uma falha e a entrega volta pela tabela de retentativas. O corpo reenviado é byte por byte o mesmo, com o mesmo id.

Guarde os ids já processados e ignore repetição. Um dia de retenção cobre com folga a janela de retentativas, que se fecha em cerca de 10 minutos:

js
async function gravarSeAusente(idDoEvento, evento) {
  // Chave única no id do evento: a segunda inserção falha em vez de duplicar o trabalho.
  const r = await db.query(
    `insert into eventos_guarita (id, corpo, recebido_em)
     values ($1, $2, now())
     on conflict (id) do nothing`,
    [idDoEvento, evento],
  );
  return r.rowCount === 1;
}

Dedupe pelo id do corpo, e não pelo x-guarita-delivery. Os dois identificam coisas diferentes: id é o evento (o mesmo para todos os destinos inscritos), enquanto x-guarita-delivery é a entrega para um destino específico e se repete nas retentativas daquela entrega. Como chave de idempotência do seu processamento, o que você quer é o evento — assim, mesmo que um dia você tenha dois destinos apontando para o mesmo serviço, o trabalho acontece uma vez só.