Webhooks de saída do BeryX
Para quem integra os alertas do BeryX no seu stack (SIEM, PagerDuty, Opsgenie, automações próprias). Contrato do canal «Webhook genérico» da página Notificações: eventos, corpo JSON, cabeçalhos, assinatura HMAC, retry e como testar. Cartão #323. Slack, Microsoft Teams e email usam os formatos desses serviços e não estão cobertos aqui.
Resumo
- Cada entrega é um
POSTHTTPS com um corpo JSON (Content-Type: application/json) e quatro cabeçalhos:X-BeryX-Event,X-BeryX-Delivery,X-BeryX-TimestampeX-BeryX-Signature. - A assinatura é
v1=seguido do HMAC-SHA256 em hexadecimal, calculado com o secret do canal sobre"{timestamp}.{corpo}". O recetor recalcula-a sobre os bytes crus do corpo e rejeita timestamps com mais de 5 minutos de diferença. Assim, uma entrega capturada não pode ser repetida. - Responda com um
2xxem menos de 10 segundos. Um5xx, um timeout ou uma ligação recusada levam até 3 novas tentativas (1 s, 5 s e 25 s depois). Um4xxé a resposta final. - O mesmo
X-BeryX-Deliveryrepete-se em todas as tentativas. Use-o para deduplicar.
Configurar o canal
- Na página Notificações, «Novo canal» → tipo Webhook genérico, com o URL
https://do seu recetor, a severidade mínima e, se quiser, o digest semanal. O host tem de ser público: um URL que aponte para um endereço interno ou privado é recusado ao gravar (ver Destinos recusados). - A resposta da criação mostra o secret de assinatura (
whsec_…) uma única vez. Guarde-o no cofre de segredos do recetor. O BeryX guarda-o cifrado (Fernet), dentro da configuração do canal, e nunca mais o devolve: nem na listagem, nem nos logs, nem no export de configuração como código. - Carregue em «Testar». A entrega de teste vai assinada e o resultado mostra o código HTTP que o recetor devolveu (ver Testar o canal).
Pela API, com uma sessão de admin ou membro do workspace:
| Operação | Pedido | Notas |
|---|---|---|
| Criar | POST /api/v1/notification-channels {"name", "kind": "webhook", "config": {"webhook_url": "https://…"}, "min_severity", "digest"} |
A resposta traz signing_secret (só aqui). Um signing_secret enviado no config é ignorado: o secret é sempre gerado pelo servidor |
| Listar | GET /api/v1/notification-channels |
signed (o canal assina), last_delivery_status (delivered | failed | skipped), last_delivery_at, last_delivery_error (HTTP 502, timeout…) |
| Rodar o secret | POST /api/v1/notification-channels/{id}/rotate-secret |
Só admins. Devolve {"signing_secret"}, uma vez |
| Testar | POST /api/v1/notification-channels/{id}/test |
Uma tentativa, sem retry. {ok, message, status_code, delivery_id, signed} |
Com domínios de acesso restrito no workspace, só um admin cria ou altera (PATCH) um canal sem
equipa, porque ele recebe os alertas de todos os datasets. Quem não é admin cria ou altera o canal
de uma equipa que não lê nada que essa pessoa não lê, como uma equipa de que é membro. Fora disso,
a resposta é 403. No PATCH contam o canal como está e como fica. Testar
(POST /api/v1/notification-channels/{id}/test) e apagar (DELETE /api/v1/notification-channels/{id})
seguem a mesma regra, para o canal como está.
Os eventos (event_kinds, opcional na criação e no PATCH) dizem o que o canal recebe:
alerts (alertas e escalation, o valor por omissão) e, por opção, os eventos de estado
dataset.verdict_changed, policy.violation_opened, policy.violation_resolved e
domain.verdict_changed (ver Eventos). Sem alerts, o canal deixa de receber
alertas. Na página Notificações é o botão «Eventos» de cada canal. No datavault.yaml é
channels[].event_kinds e no IaC (/iac/notification-channels) é event_kinds. Nos dois, um
canal sem o campo mantém o que tem.
O âmbito (scope, opcional na criação e no PATCH) limita os alertas que chegam ao canal:
domain_ids, connection_ids e dataset_ids (o canal segue qualquer um deles), issue_types e
include_auto_monitors: false (sem os monitores automáticos). Sem âmbito, o canal recebe tudo o
que passa a severidade mínima. GET /api/v1/notification-channels/{id}/preview?days=30 mostra
quantos alertas dos últimos dias teriam chegado ao canal.
Mudar o URL (PATCH ou PUT /iac/notification-channels/{id}) mantém o secret. Um canal
criado pela configuração como código (/iac, provider Terraform) também nasce com secret, mas
esse secret não é devolvido. Para o obter, rode-o uma vez.
Eventos
event |
Quando sai | Campos de contexto |
|---|---|---|
datavault.alerts |
No fim de cada scan com alertas novos warning/critical acima da severidade mínima do canal e dentro do seu âmbito, se tiver um. Os alertas do mesmo incidente (cascata pela linhagem) vêm agregados. Também sai, fora da severidade mínima e do âmbito, num canal registado como consumidor de um data product, quando o SLA desse domínio é violado com severidade crítica: uma entrega por domínio com as violações novas, sem repetir as já avisadas nesse canal |
alerts[], incidents[] |
datavault.escalation |
Um problema continua sem reconhecimento e a escala de piquete sobe de nível, ou esgota-se. Também respeita o âmbito do canal | escalation |
datavault.issue.sla_breached |
Um problema critical ou warning passa o alvo de resolução da sua severidade (SLA na política de escalation). Sai uma vez por problema, com a severidade mínima e o âmbito do canal |
sla_breach |
datavault.digest |
Resumo semanal, nos canais com digest «Semanal» | nenhum |
datavault.steward_plan |
Plano do dia do steward, nos canais escolhidos na política do steward | turn_id, report_id, period_key, counts |
datavault.test |
«Testar» na página Notificações (ou POST …/test) |
nenhum |
datavault.dataset.verdict_changed |
No fim de um scan, quando o veredito do dataset (com política, o de GET /datasets/{id}/verdict) é diferente do scan anterior. Só nos canais com dataset.verdict_changed em event_kinds |
idempotency_key, verdict_change |
datavault.policy.violation_opened |
No fim de um scan, quando uma regra da política de dados passa a ser violada no dataset | idempotency_key, policy_violation |
datavault.policy.violation_resolved |
No fim de um scan, quando uma regra deixa de ser violada (ou foi desligada) | idempotency_key, policy_violation |
datavault.domain.verdict_changed |
No fim de um scan, quando o veredito agregado de um domínio (data product, GET /domains/{id}/verdict) muda por causa dos datasets que mudaram |
idempotency_key, verdict_change |
datavault.consumer.untrusted_read |
Um agente ou aplicação regista pela Trust API (POST /trust/consumptions) que leu datasets com a decisão untrusted. Sai como aviso warning nos canais que recebem alertas, com a severidade mínima e o âmbito por ativos do canal (só os datasets do âmbito). Um canal filtrado por tipos de issue não o recebe, porque não há issue. O replay do mesmo run_ref não repete o aviso, e sai no máximo um aviso por hora por consumidor e dataset, mesmo com run_ref diferente. O workspace tem também um teto de 20 avisos por hora: o aviso que o esgota di-lo no body, e os seguintes só ficam no sino |
consumer_read |
O mesmo valor vai no cabeçalho X-BeryX-Event, para poder encaminhar a entrega sem ler o
corpo. Ignore os eventos que não conhece.
Um silêncio de alertas ativo (Notificações → «Silêncios ativos») segura as entregas do que cobre,
e os scans continuam a medir. Um problema silenciado não sai em datavault.alerts (nem no aviso
aos consumidores), datavault.escalation nem datavault.issue.sla_breached. Os eventos de estado
de um dataset silenciado, ou da conexão dele, também não saem. Um silêncio só de alguns tipos de
problema segura os de política quando inclui policy_violation, e o veredito sai na mesma. O
veredito de um domínio só fica retido com o workspace inteiro silenciado. Os alertas e os eventos
de estado retidos ficam no sino da app.
Eventos de estado (os quatro últimos):
- Saem só quando o estado muda. Sem mudança de veredito não há
verdict_changed. O BeryX guarda o veredito de cada dataset no fim de cada scan, com ou sem canais a pedir eventos. A primeira fotografia de um dataset (dataset novo, ou o primeiro scan desde que a versão com eventos de estado entrou em produção) é a linha de base e não gera eventos. Para o estado atual, useGET /api/v1/datasets/{id}/verdict. - Sai um evento por entrega. Um scan que muda o veredito de um dataset de um domínio dá um
datavault.dataset.verdict_changede umdatavault.domain.verdict_changed. idempotency_key(também no cabeçalhoIdempotency-Key) é o sha256 de sujeito + scan + tipo (+ regra). É o mesmo em todos os canais e numa repetição do mesmo scan. Deduplique por ele.- O âmbito do canal por domínios, conexões e datasets também se aplica. A severidade mínima e os tipos de issue só filtram alertas.
trust_urlaponta para os factos de confiança na API (GET …/trust-facts), que pedem uma sessão ou um tokendvk_*.
O digest semanal chegava, até à versão 1.29.0 da API, como datavault.alerts com as listas vazias.
Corpo
Exemplo completo de um datavault.alerts, num canal em português:
{
"event": "datavault.alerts",
"workspace_id": "6f1c2a9e-3d4b-4c8a-9e2f-1a7b5c3d9e10",
"occurred_at": "2026-10-06T08:15:42.118230Z",
"title": "BeryX: 1 novo alerta de qualidade de dados",
"body": "• [CRÍTICO] Nulos em clientes.nif após o load de vendas (dw.clientes → dw.vendas)\n ↳ Impacto: 3 datasets a jusante (2 pela coluna nif), 1 dashboard",
"code": "alerts_new",
"params": {"count": 1, "alerts": 2, "incidents": 1},
"alerts": [
{
"id": "0b9d5f7e-2c41-4f0a-8d6b-5e3a1c9f7b20",
"severity": "critical",
"title": "Nulos acima do limite em clientes.nif",
"issue_id": "a4e8c2d6-9f13-4b7a-8c5e-2d1f6b9a3e47"
},
{
"id": "7c3e1a9b-5d2f-4e8c-a6b4-9f0d2e7c1a53",
"severity": "warning",
"title": "Volume abaixo do esperado em vendas",
"issue_id": "e2b6d9f1-4a7c-4c3e-9b8d-7f5a1e3c6d92"
}
],
"incidents": [
{
"id": "c91f4b2e-7a6d-4d3c-8e5f-0b2a9c7d1e64",
"title": "Nulos em clientes.nif após o load de vendas",
"root_dataset_id": "5d8a2f6c-1e9b-4b7d-a3c5-8f2e6d0b9a71",
"issue_ids": ["a4e8c2d6-9f13-4b7a-8c5e-2d1f6b9a3e47", "e2b6d9f1-4a7c-4c3e-9b8d-7f5a1e3c6d92"],
"alert_ids": ["0b9d5f7e-2c41-4f0a-8d6b-5e3a1c9f7b20", "7c3e1a9b-5d2f-4e8c-a6b4-9f0d2e7c1a53"],
"impact_scope": "column",
"impact_columns": ["nif"],
"downstream_count": 3,
"downstream_column_count": 2,
"exposures_count": 1
}
]
}
E os campos de um datavault.dataset.verdict_changed (o title e o body seguem o padrão dos
outros eventos):
{
"event": "datavault.dataset.verdict_changed",
"idempotency_key": "9f2c…64 hex",
"verdict_change": {
"dataset_id": "5d8a2f6c-1e9b-4b7d-a3c5-8f2e6d0b9a71",
"dataset_name": "dw.vendas",
"connection_id": "c2a1e7d4-…",
"scan_id": "e0b4c9a2-…",
"from": "pass",
"to": "fail",
"verdict": "fail",
"score": 71.25,
"trust_url": "https://api.praxis.lbostuff.tech/api/v1/datasets/5d8a2f6c-…/trust-facts"
}
}
Num datavault.domain.verdict_changed, verdict_change traz domain_id, domain_name,
datasets_changed, from, to, scan_id e trust_url. Num datavault.policy.violation_opened
ou _resolved, policy_violation traz dataset_id, dataset_name, connection_id, scan_id,
rule, label, severity, source (workspace ou domain:<nome>), source_domain,
verdict, score e trust_url.
E o contexto de um datavault.escalation:
{"escalation": {"issue_id": "a4e8c2d6-9f13-4b7a-8c5e-2d1f6b9a3e47", "level": 1, "user_id": "3b7e…", "exhausted": false}}
E o de um datavault.issue.sla_breached:
{"sla_breach": {"issue_id": "a4e8c2d6-9f13-4b7a-8c5e-2d1f6b9a3e47", "severity": "critical", "target_hours": 24, "breached_at": "2026-09-24T14:01:00+00:00"}}
titleebodysão texto legível, pronto a mostrar, na língua do canal: olocaledoconfigdo canal, se existir, senão a língua por omissão do workspace (default_locale, emPUT /api/v1/workspace/locale), senão o português. O título de cada alerta e de cada incidente fica como o scan o gravou.titleebodysão texto simples, sem escape, e levam nomes que os utilizadores escolhem (datasets, regras, issues, equipas). Quem os mostra num chat ou numa página escapa a marcação do destino, como fazem os canais Slack e Teams do BeryX (app/domain/alerts/chat_markup.py): um nome com<!channel>ou[texto](url)não pode virar menção nem link.codeeparamsdizem o mesmo sem língua e vêm em todos os eventos desde a versão 1.73.0 da API.codeé um código estável em inglês e snake_case eparamsleva só números e identificadores. Para automatizar, use-os com os campos estruturados e os ids, e consulte o detalhe pela API (GET /api/v1/issues/{id}). Os códigos:codeeventparamsalerts_newdatavault.alertscount(incidentes e alertas soltos do envio),alerts,incidentsconsumer_sla_breacheddatavault.alerts, no aviso aos consumidores de um data productdomain_id,issuesescalation_leveldatavault.escalationissue_id,level,minutes_unackedescalation_exhausteddatavault.escalation, com a escala esgotadaissue_id,level,minutes_unackedescalation_teamdatavault.escalation, quando a escala esgotada sobe a uma equipaissue_id,level,minutes_unacked,team_idissue_sla_breacheddatavault.issue.sla_breachedissue_id,target_hoursdigest_weeklydatavault.digesthealthy,warning,critical,new_signals,expiring_tokens,steward_proposalsdigest_team_weeklydatavault.digest, num canal de uma equipateam_id,open,critical,warning,acknowledged,overdue,resolvedsteward_plandatavault.steward_plantotal,high,medium,low,suggestions_createdchannel_testdatavault.testnenhum dataset_verdict_changeddatavault.dataset.verdict_changeddataset_id,from,to,score(se houver)domain_verdict_changeddatavault.domain.verdict_changeddomain_id,from,topolicy_violation_openeddatavault.policy.violation_openeddataset_id,rule,severity,verdictpolicy_violation_resolveddatavault.policy.violation_resolveddataset_id,rule,severity,verdictconsumer_untrusted_readdatavault.consumer.untrusted_readconsumer_id,consumer_kind,datasets,warnings_cap(só no aviso que esgota o teto)O corpo nunca leva valores das suas fontes de dados nem amostras: só ids, títulos, severidades e contagens.
alertseincidentsvêm sempre, vazios fora dedatavault.alerts. Os campos de contexto só vêm no evento respetivo.Cada incidente traz o raio de impacto.
impact_scopeécolumnquando os problemas da raiz são de uma coluna (impact_columns) e a linhagem coluna-a-coluna das views diz que datasets a jusante a usam,tablequando o impacto fica ao nível da tabela (obodydiz «linhagem por coluna indisponível» se os problemas eram de uma coluna mas não havia esse mapa) enonequando nada a jusante é afetado.downstream_countconta os datasets a jusante,downstream_column_countos que o são pelas colunas da raiz eexposures_countos consumidores (exposures dbt e agentes registados).impact_scopee as três contagens vêm anull(eimpact_columnsvazio) se o impacto não se pôde calcular nesse envio.Campos novos podem aparecer numa versão minor. Não rejeite um corpo por ter campos a mais.
JSON Schema
Gerado do modelo Pydantic WebhookPayload (backend/app/api/v1/schemas.py) por
python scripts/webhook_schema.py build. Um teste falha se este bloco ficar desatualizado.
{
"$defs": {
"WebhookAlert": {
"additionalProperties": false,
"properties": {
"id": {
"description": "Id do alerta in-app.",
"title": "Id",
"type": "string"
},
"severity": {
"enum": [
"info",
"warning",
"critical"
],
"title": "Severity",
"type": "string"
},
"title": {
"title": "Title",
"type": "string"
},
"issue_id": {
"description": "Id do issue que originou o alerta (`GET /issues/{id}`).",
"title": "Issue Id",
"type": "string"
}
},
"required": [
"id",
"severity",
"title",
"issue_id"
],
"title": "WebhookAlert",
"type": "object"
},
"WebhookConsumerRead": {
"additionalProperties": false,
"description": "Cartão #166: `datavault.consumer.untrusted_read` — um consumidor registado\n(agente, aplicação, pipeline, notebook) leu dados com a decisão `untrusted`.",
"properties": {
"consumer_id": {
"title": "Consumer Id",
"type": "string"
},
"consumer_name": {
"title": "Consumer Name",
"type": "string"
},
"consumer_kind": {
"enum": [
"agent",
"application",
"pipeline",
"notebook"
],
"title": "Consumer Kind",
"type": "string"
},
"purpose": {
"description": "Finalidade declarada: agente | rag | treino | bi.",
"title": "Purpose",
"type": "string"
},
"decision": {
"const": "untrusted",
"title": "Decision",
"type": "string"
},
"run_ref": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Referência do run, se veio.",
"title": "Run Ref"
},
"datasets": {
"description": "Datasets lidos em `untrusted` dentro do âmbito do canal.",
"items": {
"$ref": "#/$defs/WebhookConsumerReadDataset"
},
"title": "Datasets",
"type": "array"
}
},
"required": [
"consumer_id",
"consumer_name",
"consumer_kind",
"purpose",
"decision",
"datasets"
],
"title": "WebhookConsumerRead",
"type": "object"
},
"WebhookConsumerReadDataset": {
"additionalProperties": false,
"properties": {
"dataset_id": {
"title": "Dataset Id",
"type": "string"
},
"dataset_name": {
"title": "Dataset Name",
"type": "string"
}
},
"required": [
"dataset_id",
"dataset_name"
],
"title": "WebhookConsumerReadDataset",
"type": "object"
},
"WebhookEscalation": {
"additionalProperties": false,
"properties": {
"issue_id": {
"title": "Issue Id",
"type": "string"
},
"level": {
"description": "Nível da escala (0 = primeiro de piquete).",
"title": "Level",
"type": "integer"
},
"user_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Pessoa de piquete paginada; null se esgotada.",
"title": "User Id"
},
"exhausted": {
"description": "A escala esgotou-se: não volta a paginar.",
"title": "Exhausted",
"type": "boolean"
}
},
"required": [
"issue_id",
"level",
"user_id",
"exhausted"
],
"title": "WebhookEscalation",
"type": "object"
},
"WebhookIncident": {
"additionalProperties": false,
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Título do incidente (cascata por linhagem).",
"title": "Title"
},
"root_dataset_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Root Dataset Id"
},
"issue_ids": {
"items": {
"type": "string"
},
"title": "Issue Ids",
"type": "array"
},
"alert_ids": {
"description": "Alertas deste envio que pertencem ao incidente.",
"items": {
"type": "string"
},
"title": "Alert Ids",
"type": "array"
},
"impact_scope": {
"anyOf": [
{
"enum": [
"column",
"table",
"none"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Raio de impacto: `column` (os problemas da raiz são de uma coluna e o impacto segue a linhagem coluna-a-coluna das views), `table` (ao nível da tabela) ou `none` (nada a jusante). null se não se calculou.",
"title": "Impact Scope"
},
"impact_columns": {
"description": "Colunas da raiz a que os problemas se referem.",
"items": {
"type": "string"
},
"title": "Impact Columns",
"type": "array"
},
"downstream_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Datasets a jusante afetados (certos e possíveis).",
"title": "Downstream Count"
},
"downstream_column_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Dos datasets a jusante, os afetados pelas colunas da raiz.",
"title": "Downstream Column Count"
},
"exposures_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Consumidores afetados: exposures dbt e agentes registados.",
"title": "Exposures Count"
}
},
"required": [
"id",
"title",
"root_dataset_id",
"issue_ids",
"alert_ids"
],
"title": "WebhookIncident",
"type": "object"
},
"WebhookPolicyViolation": {
"additionalProperties": false,
"description": "Cartão #155: `datavault.policy.violation_opened` / `…_resolved`.",
"properties": {
"dataset_id": {
"title": "Dataset Id",
"type": "string"
},
"dataset_name": {
"title": "Dataset Name",
"type": "string"
},
"connection_id": {
"title": "Connection Id",
"type": "string"
},
"scan_id": {
"title": "Scan Id",
"type": "string"
},
"rule": {
"description": "Regra da política de dados (ex.: `require_owner`).",
"title": "Rule",
"type": "string"
},
"label": {
"description": "Nome da regra (pt-PT).",
"title": "Label",
"type": "string"
},
"severity": {
"enum": [
"warning",
"critical"
],
"title": "Severity",
"type": "string"
},
"source": {
"description": "Quem impôs a regra: `workspace` ou `domain:<nome>`.",
"title": "Source",
"type": "string"
},
"source_domain": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Nome do domínio de origem, se houver.",
"title": "Source Domain"
},
"verdict": {
"description": "Veredito do dataset neste scan.",
"enum": [
"pass",
"warn",
"fail",
"unknown"
],
"title": "Verdict",
"type": "string"
},
"score": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Score"
},
"trust_url": {
"title": "Trust Url",
"type": "string"
}
},
"required": [
"dataset_id",
"dataset_name",
"connection_id",
"scan_id",
"rule",
"label",
"severity",
"source",
"source_domain",
"verdict",
"trust_url"
],
"title": "WebhookPolicyViolation",
"type": "object"
},
"WebhookSlaBreach": {
"additionalProperties": false,
"description": "Cartão #396: `datavault.issue.sla_breached` — um issue passou o alvo de resolução.",
"properties": {
"issue_id": {
"title": "Issue Id",
"type": "string"
},
"severity": {
"enum": [
"warning",
"critical"
],
"title": "Severity",
"type": "string"
},
"target_hours": {
"description": "Alvo de resolução da severidade, em horas.",
"title": "Target Hours",
"type": "integer"
},
"breached_at": {
"description": "Quando o relógio do SLA chegou ao alvo (UTC).",
"format": "date-time",
"title": "Breached At",
"type": "string"
}
},
"required": [
"issue_id",
"severity",
"target_hours",
"breached_at"
],
"title": "WebhookSlaBreach",
"type": "object"
},
"WebhookStewardCounts": {
"additionalProperties": false,
"properties": {
"total": {
"title": "Total",
"type": "integer"
},
"by_risk": {
"additionalProperties": {
"type": "integer"
},
"title": "By Risk",
"type": "object"
},
"by_kind": {
"additionalProperties": {
"type": "integer"
},
"title": "By Kind",
"type": "object"
},
"suggestions_created": {
"title": "Suggestions Created",
"type": "integer"
}
},
"required": [
"total",
"by_risk",
"by_kind",
"suggestions_created"
],
"title": "WebhookStewardCounts",
"type": "object"
},
"WebhookVerdictChange": {
"additionalProperties": false,
"description": "Cartão #155: `datavault.dataset.verdict_changed` / `datavault.domain.verdict_changed`.",
"properties": {
"from": {
"description": "Veredito do scan anterior.",
"enum": [
"pass",
"warn",
"fail",
"unknown"
],
"title": "From",
"type": "string"
},
"to": {
"description": "Veredito deste scan (com política).",
"enum": [
"pass",
"warn",
"fail",
"unknown"
],
"title": "To",
"type": "string"
},
"scan_id": {
"title": "Scan Id",
"type": "string"
},
"trust_url": {
"description": "Factos de confiança na API (`GET …/trust-facts`).",
"title": "Trust Url",
"type": "string"
},
"dataset_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Só num dataset.",
"title": "Dataset Id"
},
"dataset_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Dataset Name"
},
"connection_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Connection Id"
},
"verdict": {
"anyOf": [
{
"enum": [
"pass",
"warn",
"fail",
"unknown"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Igual a `to` (dataset).",
"title": "Verdict"
},
"score": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Score de qualidade deste scan.",
"title": "Score"
},
"domain_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Só num domínio (data product).",
"title": "Domain Id"
},
"domain_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Domain Name"
},
"datasets_changed": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Domínio: datasets que mudaram de veredito neste scan.",
"title": "Datasets Changed"
}
},
"required": [
"from",
"to",
"scan_id",
"trust_url"
],
"title": "WebhookVerdictChange",
"type": "object"
}
},
"additionalProperties": false,
"description": "Corpo JSON de uma entrega do webhook genérico do BeryX.\n\nCampos novos podem aparecer numa versão minor: ignore os que não conhece.\n`alerts` e `incidents` vêm sempre (vazios fora de `datavault.alerts`), tal como\n`code` e `params` (cartão #519); os campos de contexto só vêm no evento respetivo.",
"properties": {
"event": {
"description": "Tipo do evento; também no cabeçalho X-DataVault-Event.",
"enum": [
"datavault.alerts",
"datavault.escalation",
"datavault.digest",
"datavault.steward_plan",
"datavault.test",
"datavault.dataset.verdict_changed",
"datavault.policy.violation_opened",
"datavault.policy.violation_resolved",
"datavault.domain.verdict_changed",
"datavault.consumer.untrusted_read",
"datavault.issue.sla_breached"
],
"title": "Event",
"type": "string"
},
"workspace_id": {
"title": "Workspace Id",
"type": "string"
},
"occurred_at": {
"description": "Quando o evento foi gerado (UTC, ISO 8601).",
"format": "date-time",
"title": "Occurred At",
"type": "string"
},
"title": {
"description": "Resumo legível, na língua do canal: `config.locale`, senão a omissão do workspace (`default_locale`), senão pt-PT.",
"title": "Title",
"type": "string"
},
"body": {
"description": "Texto legível na língua do canal, uma linha por alerta/incidente.",
"title": "Body",
"type": "string"
},
"code": {
"description": "O que o `title`/`body` dizem, como código estável em inglês e snake_case (ex.: `alerts_new`, `escalation_level`): para compor o seu texto ou automatizar.",
"title": "Code",
"type": "string"
},
"params": {
"additionalProperties": {
"anyOf": [
{
"type": "integer"
},
{
"type": "number"
},
{
"type": "string"
}
]
},
"description": "Parâmetros do `code`: só números e identificadores, nunca prosa.",
"title": "Params",
"type": "object"
},
"alerts": {
"items": {
"$ref": "#/$defs/WebhookAlert"
},
"title": "Alerts",
"type": "array"
},
"incidents": {
"items": {
"$ref": "#/$defs/WebhookIncident"
},
"title": "Incidents",
"type": "array"
},
"escalation": {
"anyOf": [
{
"$ref": "#/$defs/WebhookEscalation"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.escalation."
},
"turn_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.steward_plan.",
"title": "Turn Id"
},
"report_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.steward_plan.",
"title": "Report Id"
},
"period_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.steward_plan (dia do plano, AAAA-MM-DD).",
"title": "Period Key"
},
"counts": {
"anyOf": [
{
"$ref": "#/$defs/WebhookStewardCounts"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.steward_plan."
},
"idempotency_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Só nos eventos de estado: igual ao cabeçalho Idempotency-Key — o mesmo em todos os canais e numa repetição do scan.",
"title": "Idempotency Key"
},
"verdict_change": {
"anyOf": [
{
"$ref": "#/$defs/WebhookVerdictChange"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.dataset.verdict_changed e datavault.domain.verdict_changed."
},
"policy_violation": {
"anyOf": [
{
"$ref": "#/$defs/WebhookPolicyViolation"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.policy.violation_opened e datavault.policy.violation_resolved."
},
"consumer_read": {
"anyOf": [
{
"$ref": "#/$defs/WebhookConsumerRead"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.consumer.untrusted_read."
},
"sla_breach": {
"anyOf": [
{
"$ref": "#/$defs/WebhookSlaBreach"
},
{
"type": "null"
}
],
"default": null,
"description": "Só em datavault.issue.sla_breached."
}
},
"required": [
"event",
"workspace_id",
"occurred_at",
"title",
"body",
"code",
"params",
"alerts",
"incidents"
],
"title": "WebhookPayload",
"type": "object"
}
Cabeçalhos
| Cabeçalho | Exemplo | Para quê |
|---|---|---|
X-BeryX-Event |
datavault.alerts |
Tipo do evento (igual a event no corpo) |
X-BeryX-Delivery |
5f0c9a1e-… (UUID) |
Id da entrega. É o mesmo em todas as tentativas: deduplique por ele |
X-BeryX-Timestamp |
1791274542 |
Segundos Unix do envio desta tentativa. Entra na assinatura |
X-BeryX-Signature |
v1=7b4fddea… |
HMAC-SHA256 em hex de "{timestamp}.{corpo}" com o secret do canal |
Idempotency-Key |
9f2c… (64 hex) |
Só nos eventos de estado: igual a idempotency_key no corpo, o mesmo em todos os canais e numa repetição do scan |
X-BeryX-Signature pode trazer várias assinaturas separadas por vírgula (v1=…,v1=…),
por exemplo numa futura janela de rotação com dois secrets ativos. Aceite a entrega se
alguma bater. Canais criados antes do cartão #323 não têm secret e enviam sem
X-BeryX-Signature até um admin rodar o secret. Os outros três cabeçalhos vão sempre.
Até 31 de maio de 2027, cada um dos quatro X-BeryX-* sai também com o nome antigo,
X-DataVault-*, e o mesmo valor. A assinatura cobre "{timestamp}.{corpo}" e não o nome do
cabeçalho, por isso um recetor que ainda lê X-DataVault-Signature continua a validar as
entregas. Mude o recetor para os X-BeryX-* antes dessa data.
Verificar a assinatura
Três regras:
- Calcule o HMAC sobre os bytes crus do corpo, antes de qualquer parse. Re-serializar o JSON muda espaços e a ordem das chaves, e a assinatura deixa de bater.
- Compare em tempo constante (
hmac.compare_digest,crypto.timingSafeEqual). - Rejeite timestamps com mais de 300 segundos de diferença do seu relógio. É isto que trava a repetição (replay) de uma entrega capturada. Mantenha o relógio do recetor sincronizado por NTP.
Vetor de teste: secret whsec_test, timestamp 1700000000 e corpo {"event":"datavault.test"}
dão v1=7b4fddeaeedd31820bf0f0affa3881d841e08efaedff83f5b685059373143810.
Python (biblioteca padrão):
import hashlib, hmac, time
def verify(secret: str, headers, body: bytes, tolerance: int = 300) -> bool:
ts = headers.get("X-BeryX-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False # fora da janela: replay ou relógio errado
expected = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
received = [s.strip()[3:] for s in headers.get("X-BeryX-Signature", "").split(",")
if s.strip().startswith("v1=")]
return any(hmac.compare_digest(expected, r) for r in received)
Node.js (≥ 18, node:crypto). rawBody é o Buffer do corpo (por exemplo, express.raw({ type: "application/json" })):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, headers, rawBody, toleranceS = 300) {
const ts = headers["x-beryx-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > toleranceS) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(`${ts}.`).update(rawBody).digest("hex"));
return (headers["x-beryx-signature"] ?? "")
.split(",")
.map((s) => Buffer.from(s.trim().replace(/^v1=/, "")))
.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}
Um recetor completo e executável, em FastAPI (verificação, janela de 5 minutos e deduplicação
por X-BeryX-Delivery), está em backend/scripts/webhook_receiver.py:
pip install fastapi uvicorn
BERYX_WEBHOOK_SECRET=whsec_… uvicorn webhook_receiver:app --port 9000
O mesmo ficheiro serve de recetor de referência nos testes do produto (backend/tests/test_webhooks.py).
Retry e entregas duplicadas
| Resposta do recetor | O que o BeryX faz |
|---|---|
2xx |
Entregue. last_delivery_status = delivered |
5xx, timeout (10 s), ligação recusada ou cortada |
Nova tentativa 1 s, 5 s e 25 s depois da anterior: no máximo 4 envios em ~70 s |
4xx (incluindo 401/403 de uma assinatura recusada e 429) |
Resposta final, sem nova tentativa. last_delivery_status = failed |
3xx |
Os redirecionamentos não são seguidos: conta como falha. Configure o URL final |
| O host do URL resolve para um endereço interno ou privado | Destino recusado (proteção anti-SSRF), sem nova tentativa. last_delivery_error = destino recusado (SSRF) |
- Cada tentativa leva um
X-BeryX-Timestampe uma assinatura novos (a janela de 5 minutos conta a partir do envio real). OX-BeryX-Deliveryé sempre o mesmo. - Uma resposta perdida no caminho de volta pode fazer chegar a mesma entrega duas vezes.
Deduplique por
X-BeryX-Delivery: se já o processou, responda200e não faça nada. - Esgotadas as tentativas, a entrega é dada como falhada: não há fila persistida nem
reenvio posterior. A falha fica visível no cartão do canal («Última entrega: falhou
(HTTP 502)») e em
GET /notification-channels, e o alerta continua disponível na app. - Responda depressa (
202e processamento assíncrono do seu lado). Um recetor lento atrasa as entregas seguintes para os outros canais do mesmo workspace.
Testar o canal
«Testar» (ou POST /api/v1/notification-channels/{id}/test) envia um datavault.test
assinado, numa só tentativa, e mostra o resultado da verificação feita pelo seu recetor:
| Resultado | Significado |
|---|---|
| «Entrega assinada aceite pelo recetor (HTTP 200)» | O recetor validou a assinatura e respondeu 2xx |
| «O recetor recusou a assinatura (HTTP 401)» | A verificação falhou do lado do recetor: o secret não é o atual (foi rodado?), ou o HMAC é calculado sobre o JSON re-serializado em vez dos bytes crus |
| «Entrega aceite, mas sem assinatura» | Canal anterior ao cartão #323: rode o secret para ligar a assinatura |
| «Falha no envio: HTTP 5xx / timeout» | O recetor não respondeu ou deu erro. Em produção haveria retry |
| «Falha no envio: destino recusado (SSRF)» | O host do URL resolve agora para um endereço interno ou privado. Nada foi enviado (ver Destinos recusados) |
O resultado fica também em «Última entrega» no cartão do canal.
Rodar o secret
«Rodar secret» (só admins), ou POST …/rotate-secret, gera um secret novo e mostra-o uma
vez. A troca é imediata: as entregas seguintes já vão assinadas com o novo. Para não perder
entregas durante a troca, prepare o recetor para aceitar o secret antigo e o novo durante
alguns minutos. Depois de rodar, atualize-o e retire o antigo. A rotação fica registada na
Auditoria («Secret do webhook rodado»).
Se o secret vazar, rode-o logo. Quem o tiver consegue forjar entregas para o seu recetor.
Destinos recusados
O URL do webhook vem da configuração do workspace, por isso não pode apontar para dentro da infraestrutura do BeryX (proteção anti-SSRF, SEGURANCA.md):
- O host tem de resolver só para endereços públicos. Um IP de loopback, link-local (incluindo a
metadata da cloud,
169.254.169.254emetadata.google.internal) ou de rede privada é recusado, tal como um host que não resolve. - A validação corre ao gravar o canal (na app e na API de IaC,
400/422com o motivo) e de novo em cada envio e em cada tentativa do retry, com a ligação fixada ao IP validado. Se o DNS do host mudar depois de gravado para um endereço interno, o envio é recusado. - Uma entrega recusada não se repete e fica em «Última entrega» como «destino recusado (SSRF)».
- Numa instância dedicada com o recetor na rede do cliente, o operador pode isentar esse host
em
DATAVAULT_CONNECTOR_ALLOWED_HOSTS. Ohttpscontinua obrigatório.
O URL do webhook é tratado como segredo: não aparece na resposta do «Testar», em
last_delivery_error nem nos logs do BeryX, que guardam só o código HTTP ou o tipo do erro.
IPs de saída
Os webhooks saem da infraestrutura do BeryX na Google Cloud (europe-west1). O IP de saída
fixo, partilhado por região, está preparado mas ainda por ativar em produção (#248, ver
REDE_ALLOWLIST.md). Até lá, não filtre o recetor por IP de origem: use a
assinatura. Depois da ativação, o IP em vigor aparece em
GET https://api.praxis.lbostuff.tech/api/v1/public/egress-ips. Pode autorizá-lo como camada
adicional, nunca em vez da assinatura.
Limites conhecidos
- Sem fila persistida nem reenvio manual de entregas antigas. O histórico de entregas fica
apenas na última (
last_delivery_*). - Os eventos de estado começam na primeira fotografia de cada dataset: o que mudou antes disso não é reenviado.
Webhooks de entrada dos tickets (Jira, Linear)
O sentido inverso, cartão #381: o Jira ou o Linear avisam o BeryX quando um ticket ligado
a um problema muda de estado. São opcionais. Sem eles, o BeryX lê o estado de cada ticket
open, in_progress ou unknown, e de cada ticket done cujo problema voltou a abrir, de 15
em 15 minutos (polling); com eles, a mudança aplica-se assim que o evento chega.
- Ligar: em
PUT /api/v1/notification-channels/tickets/{integration_id}/sync(admin), definawebhook_secret(16 caracteres ou mais). A resposta trazwebhook_path, o caminho a registar no fornecedor:POST /api/v1/webhooks/tickets/{integration_id}. Umwebhook_secretvazio desliga-os. - Assinatura: o Jira manda
X-Hub-Signature: sha256=<hex>e o LinearLinear-Signature: <hex>, ambos HMAC-SHA256 do corpo cru com o segredo. O BeryX verifica-a antes de interpretar o JSON. Assinatura inválida, ou integração sem segredo, dá401e não muda nada. Um evento sem o carimbo do fornecedor (Jiratimestamp, LinearwebhookTimestamp), ou com mais de 5 minutos de diferença, dá400: um corpo assinado e capturado deixa de valer. - Limites: 120 pedidos por minuto por integração e IP (
429comRetry-After) e corpo até 1 MB (413). O anti-SSRF não se aplica: o BeryX só recebe. - Resposta:
200comstatusigual aapplied,unchanged,ignored(evento que não muda estado),unknown_ticketouworkspace_disabled. As entregas sobre um ticket ligado (applied,unchanged) e as de um workspace suspenso ficam na auditoria (ticket_webhook_received) com o fornecedor, a chave, o estado e o resultado, nunca o corpo. As outras (ignored,unknown_ticket, por exemplo de um filtro largo no Jira) ficam só no log.
O estado lê-se pela categoria (Jira statusCategory, Linear state.type), não pelo nome que cada
equipa dá às colunas. Um ticket fechado (done) num problema aberto resolve-o. Se o problema
for de uma regra, fica à espera de verificação: o scan seguinte que avalia a regra confirma-o
(continua resolvido) ou reabre-o. Cada mudança grava um evento no histórico do problema com a
origem ticket:<provider>:<key>. No sentido contrário, com comment ligado (omissão), o
BeryX comenta no ticket quando o problema é resolvido, verificado, reaberto ou ignorado. Só
diz «verificado» ou «a verificação falhou» se o estado do problema for o que o scan deixou, e
indica a chave do ticket que fechou. Se alguém resolver ou reabrir o problema no BeryX antes
de o comentário seguir, o comentário diz só que foi resolvido ou reaberto. Com transition,
também fecha o ticket, se ainda não estiver fechado, ou reabre-o, se estiver. Os comentários e a
transição saem com as credenciais da integração e qualquer membro com escrita pode ligar um
ticket à mão. Por isso só chegam aos tickets do projeto (Jira) ou da equipa (Linear) configurados
na integração; no Linear, a equipa lê-se no fornecedor antes de escrever. Um ticket de outro
projeto ou equipa já ligado à integração continua a ser lido, mas não recebe nada. Ao ligar um
ticket (POST /api/v1/tickets/link) a uma integração do Jira ou do Linear, a chave indicada tem
de ser a do URL, quando o URL a traz (422). No Jira, uma chave fora do projeto da integração
escolhida em integration_id dá 422; sem integration_id, como na aplicação, o ticket fica
ligado sem integração e fora da sincronização. O interval_min
(5 a 1440) muda o intervalo do polling. Um ticket que dá erro só dele (apagado no fornecedor,
chave inválida) fica para o fim da fila e os outros seguem; um aviso que ele não pode receber
perde-se. Um fornecedor que responde 429, recusa as credenciais ou está em baixo adia a
integração, com espera a dobrar até 6 horas. No ServiceNow, no Azure DevOps, no GitHub e no
GitLab, o polling lê o estado dos tickets; os comentários, a transição e os webhooks de entrada
são, por agora, só do Jira e do Linear. Nesses quatro, o PUT …/sync recusa (400) comment
ou transition a true e um webhook_secret, a política devolve-os a false e o
webhook_path vem nulo.