Saltar para o conteúdo
BeryX
Voltar às respostas para RFP

Documentação do BeryX

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 POST HTTPS com um corpo JSON (Content-Type: application/json) e quatro cabeçalhos: X-BeryX-Event, X-BeryX-Delivery, X-BeryX-Timestamp e X-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 2xx em menos de 10 segundos. Um 5xx, um timeout ou uma ligação recusada levam até 3 novas tentativas (1 s, 5 s e 25 s depois). Um 4xx é a resposta final.
  • O mesmo X-BeryX-Delivery repete-se em todas as tentativas. Use-o para deduplicar.

Configurar o canal

  1. 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).
  2. 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.
  3. 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, use GET /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_changed e um datavault.domain.verdict_changed.
  • idempotency_key (também no cabeçalho Idempotency-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_url aponta para os factos de confiança na API (GET …/trust-facts), que pedem uma sessão ou um token dvk_*.

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"}}
  • title e body são texto legível, pronto a mostrar, na língua do canal: o locale do config do canal, se existir, senão a língua por omissão do workspace (default_locale, em PUT /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.

  • title e body sã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.

  • code e params dizem 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 e params leva 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:

    code event params
    alerts_new datavault.alerts count (incidentes e alertas soltos do envio), alerts, incidents
    consumer_sla_breached datavault.alerts, no aviso aos consumidores de um data product domain_id, issues
    escalation_level datavault.escalation issue_id, level, minutes_unacked
    escalation_exhausted datavault.escalation, com a escala esgotada issue_id, level, minutes_unacked
    escalation_team datavault.escalation, quando a escala esgotada sobe a uma equipa issue_id, level, minutes_unacked, team_id
    issue_sla_breached datavault.issue.sla_breached issue_id, target_hours
    digest_weekly datavault.digest healthy, warning, critical, new_signals, expiring_tokens, steward_proposals
    digest_team_weekly datavault.digest, num canal de uma equipa team_id, open, critical, warning, acknowledged, overdue, resolved
    steward_plan datavault.steward_plan total, high, medium, low, suggestions_created
    channel_test datavault.test nenhum
    dataset_verdict_changed datavault.dataset.verdict_changed dataset_id, from, to, score (se houver)
    domain_verdict_changed datavault.domain.verdict_changed domain_id, from, to
    policy_violation_opened datavault.policy.violation_opened dataset_id, rule, severity, verdict
    policy_violation_resolved datavault.policy.violation_resolved dataset_id, rule, severity, verdict
    consumer_untrusted_read datavault.consumer.untrusted_read consumer_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.

  • alerts e incidents vêm sempre, vazios fora de datavault.alerts. Os campos de contexto só vêm no evento respetivo.

  • Cada incidente traz o raio de impacto. impact_scope é column quando 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, table quando o impacto fica ao nível da tabela (o body diz «linhagem por coluna indisponível» se os problemas eram de uma coluna mas não havia esse mapa) e none quando nada a jusante é afetado. downstream_count conta os datasets a jusante, downstream_column_count os que o são pelas colunas da raiz e exposures_count os consumidores (exposures dbt e agentes registados). impact_scope e as três contagens vêm a null (e impact_columns vazio) 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:

  1. 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.
  2. Compare em tempo constante (hmac.compare_digest, crypto.timingSafeEqual).
  3. 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-Timestamp e uma assinatura novos (a janela de 5 minutos conta a partir do envio real). O X-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, responda 200 e 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 (202 e 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.254 e metadata.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/422 com 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. O https continua 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), defina webhook_secret (16 caracteres ou mais). A resposta traz webhook_path, o caminho a registar no fornecedor: POST /api/v1/webhooks/tickets/{integration_id}. Um webhook_secret vazio desliga-os.
  • Assinatura: o Jira manda X-Hub-Signature: sha256=<hex> e o Linear Linear-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á 401 e não muda nada. Um evento sem o carimbo do fornecedor (Jira timestamp, Linear webhookTimestamp), 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 (429 com Retry-After) e corpo até 1 MB (413). O anti-SSRF não se aplica: o BeryX só recebe.
  • Resposta: 200 com status igual a applied, unchanged, ignored (evento que não muda estado), unknown_ticket ou workspace_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.