▸ API · Webhooks

Visão geral

Cadastro do destino, o envelope, os cabeçalhos, entrega e retentativas.

atualizado em 2 set 2026versão v1

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.

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:

CampoDescrição
id
stringsempre
Id do EVENTO (evt_ + UUID). Estável entre reentregas — é por ele que se deduplica.
event
stringsempre
Qual evento.
scan.completedscan.failedfinding.opened
createdAt
stringsempre
Quando o evento aconteceu — string ISO 8601 em UTC (2026-08-27T14:09:24.106Z).
data
objectsempre
Carga específica do evento (veja cada um).
POST · application/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.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çalhoExemploPra 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 de <t>.<corpo cru>, com o segredo do destino.
x-guarita-deliverydlv_5a1e0c93-…Id desta ENTREGA (uma por destino, por evento). Repete nas retentativas.

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:

TentativaEspera desde a anteriorAcumulado desde o evento
1imediata, junto com o evento0
215 s~15 s
330 s~45 s
41 min~1 min 45 s
52 min~3 min 45 s
64 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 destinoTratamento
2xxEntrega concluída. O corpo da resposta não é lido.
3xxDefinitivo: redirect não é seguido. Cadastre a URL final (o caso clássico é a barra no fim que o proxy redireciona).
4xx, exceto 429Definitivo: sem retentativa. O destino recusou o corpo; insistir só gasta tentativa.
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.
  • 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

Receba, grave, responda 2xx, processe depois.
Os 10 segundos valem pra resposta inteira. Um endpoint que gera PDF e chama três APIs antes de responder estoura o timeout, conta como falha e recebe o mesmo evento de novo — com o trabalho anterior feito pela metade.
handler.mjs — Express
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.

Achou algo errado ou faltando? help@guarita.dev — com o x-request-id, se for sobre uma resposta.