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
| Evento | Significado | Quando sai |
|---|---|---|
scan.completed | Uma ronda terminou | Toda ronda que passa pela fila de análise e é gravada como done |
scan.failed | Uma ronda falhou | A ronda esgotou as tentativas ou estourou o tempo sem produzir resultado |
finding.opened | Uma 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:
| Campo | Tipo | O que é |
|---|---|---|
id | string | Id do EVENTO (evt_ + UUID). Estável entre reentregas — é por ele que se deduplica |
event | string | Um dos eventos da tabela acima |
createdAt | string | Quando o evento aconteceu, ISO 8601 em UTC |
data | objeto | Carga 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
{
"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_RISKouCRITICAL_RISK.critical_countconta os achados de severidadecritical. O par score + críticos é o que costuma decidir se um pipeline falha.blockedétruequando 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_urlaponta 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
{
"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) ouclean.previous_risk_scoreé o score do baseline, ounullquando não havia ronda anterior.openederesolvedsã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çalho | Exemplo | Para que serve |
|---|---|---|
content-type | application/json | O corpo é JSON em UTF-8 |
user-agent | Guarita-Webhook/1 | Identifica a origem em log e regra de WAF. Não é autenticação |
x-guarita-signature | t=1788013327,v1=8f3c… | HMAC SHA-256 do conteúdo <t>.<corpo cru>, com o segredo do destino |
x-guarita-delivery | dlv_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:
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:
| Tentativa | Espera desde a anterior | Acumulado desde o evento |
|---|---|---|
| 1 | imediata, junto com o evento | 0 |
| 2 | 15 segundos | ~15 s |
| 3 | 30 segundos | ~45 s |
| 4 | 1 minuto | ~1 min 45 s |
| 5 | 2 minutos | ~3 min 45 s |
| 6 | 4 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 destino | Tratamento |
|---|---|
2xx | Entrega concluída. Não lemos o corpo da resposta |
4xx, exceto 429 | Definitivo: paramos na hora, sem retentativa |
429 | Temporário: entra na fila de retentativa |
5xx | Temporário: entra na fila de retentativa |
| Timeout, DNS, TLS, conexão recusada | Temporá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.
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:
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ó.