Visão geral
Cadastro do destino, o envelope, os cabeçalhos, entrega e retentativas.
Como funciona
Um webhook inverte quem chama: a Guarita faz um POST na SUA URL quando uma ronda termina, falha ou abre brecha nova. É o caminho de volta da API — em vez de você perguntar em GET /scans de minuto em minuto, a resposta chega.
Polling e webhook resolvem o mesmo problema. Escolha um.
Cadastrar um destino
No painel, em Configurações → Desenvolvedores. Só quem é dono da conta cadastra: um destino recebe os achados de segurança dos seus apps, então fica na mesma família de cobrança e dados fiscais.
O cadastro pede a URL e quais eventos ela recebe. Regras, todas com mensagem na tela:
- URL completa em
https://— o corpo carrega brechas do seu app, e em HTTP isso viajaria legível. - Host público. Endereço interno (localhost, rede privada, metadados de nuvem) é recusado: a chamada sai da nossa rede, e aceitar qualquer destino seria um SSRF de bandeja.
- Ao menos um evento marcado.
- A mesma URL não entra duas vezes na conta.
- Máximo de 5 destinos por conta.
gwh_…) uma única vez. Copie na hora e guarde no cofre de segredos da sua aplicação. Perdeu? Remova o destino e cadastre outro — o novo vem com segredo novo.A lista de destinos mostra o resultado da última tentativa (status HTTP e erro). É o primeiro lugar pra olhar quando a pergunta é “por que não está chegando?”.
O envelope
Todo evento chega no corpo do POST com a mesma estrutura de quatro campos:
| Campo | Descrição |
|---|---|
idstringsempre | Id do EVENTO (evt_ + UUID). Estável entre reentregas — é por ele que se deduplica. |
eventstringsempre | Qual evento.scan.completedscan.failedfinding.opened |
createdAtstringsempre | Quando o evento aconteceu — string ISO 8601 em UTC (2026-08-27T14:09:24.106Z). |
dataobjectsempre | Carga específica do evento (veja cada um). |
{
"id": "evt_9f1c2a54-4c1e-4a0c-9a1f-7b6a2f0d8e33",
"event": "scan.completed",
"createdAt": "2026-08-29T14:22:07.481Z",
"data": {
"scan_id": "scan_5e2a91c4",
"hostname": "app.exemplo.com.br",
"target": "https://app.exemplo.com.br",
"risk_score": 41,
"risk_label": "MODERATE_RISK",
"critical_count": 1,
"blocked": false,
"report_url": "https://www.guarita.dev/scans/scan_5e2a91c4"
}
}O mesmo id vai pra todos os destinos inscritos naquele evento. Dois destinos no mesmo serviço veem o mesmo id duas vezes — não é reentrega, são duas assinaturas do mesmo aviso. Ver o que chega em cada evento.
Os cabeçalhos
| Cabeçalho | Exemplo | Pra 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 de <t>.<corpo cru>, com o segredo do destino. |
x-guarita-delivery | dlv_5a1e0c93-… | Id desta ENTREGA (uma por destino, por evento). Repete nas retentativas. |
user-agent qualquer um falsifica, e a URL do destino pode vazar em log. Sem conferir a assinatura, seu endpoint aceita qualquer POST que alguém montar. Como verificar a assinatura.Entrega e retentativas
A primeira tentativa sai junto com o evento, sem esperar ciclo nenhum. Cada tentativa tem 10 segundos de timeout pra resposta inteira. Falhou? A entrega volta pra fila com espera crescente, até 6 tentativas:
| Tentativa | Espera desde a anterior | Acumulado desde o evento |
|---|---|---|
| 1 | imediata, junto com o evento | 0 |
| 2 | 15 s | ~15 s |
| 3 | 30 s | ~45 s |
| 4 | 1 min | ~1 min 45 s |
| 5 | 2 min | ~3 min 45 s |
| 6 | 4 min | ~7 min 45 s |
Os reenvios são processados por um ciclo que roda a cada 30 segundos — cada retentativa pode acontecer até meio minuto depois do horário da tabela. Depois da 6ª falha, desistimos daquela entrega; não há fila morta.
O que conta como falha depende da sua resposta:
| Resposta do destino | Tratamento |
|---|---|
| 2xx | Entrega concluída. O corpo da resposta não é lido. |
| 3xx | Definitivo: redirect não é seguido. Cadastre a URL final (o caso clássico é a barra no fim que o proxy redireciona). |
| 4xx, exceto 429 | Definitivo: sem retentativa. O destino recusou o corpo; insistir só gasta tentativa. |
| 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. |
- Quando uma entrega desiste (3xx, 4xx que não seja 429, ou as 6 tentativas esgotadas), a Guarita manda um e-mail ao dono da conta com o motivo em português. Uma vez por sequência de falhas, não a cada tentativa.
- Remover o destino encerra as entregas pendentes dele — nada fica tentando contra uma URL que não existe mais.
- O corpo reenviado é byte a byte o mesmo, com o mesmo
id: a assinatura que você confere não muda entre tentativas.
Escrevendo o handler
import express from "express";
const app = express();
// express.raw preserva os BYTES do corpo. A assinatura cobre o corpo cru:
// JSON.parse + JSON.stringify reordena as 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 pra Guarita: sem 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` devolve false quando
// o id já existe — deduplicação e fila local no mesmo passo.
const novo = await gravarSeAusente(evento.id, evento);
res.status(204).send(); // responde ANTES do trabalho pesado
if (novo) {
processar(evento).catch((err) => console.error("[guarita] falha ao processar", err));
}
});
async function gravarSeAusente(idDoEvento, evento) {
// Chave única no id do evento: a segunda inserção não duplica 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;
}
app.listen(3000);Seu handler precisa ser idempotente: se a sua resposta se perder (timeout por meio segundo, 502 do proxy depois de gravar), do nosso lado é falha e a entrega volta. Deduplique pelo id do evento, não pelo x-guarita-delivery: o id da entrega muda a cada destino e repete a cada retentativa; o do evento é o que identifica o aviso.
Plano
Webhooks fazem parte dos planos Pro e Business, junto com a API. A verificação acontece no momento do envio: se a conta cair pra um plano sem o recurso, os destinos continuam cadastrados e as entregas param. Voltando o plano, voltam sozinhas.
Cadastrar exige o plano; remover, não. Tirar do ar uma URL que você não controla mais é redução de superfície — ninguém deveria precisar voltar a pagar pra conseguir isso.
x-request-id, se for sobre uma resposta.