Saltar para o conteúdo
BeryxTechBeryxTech
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 e os eventos de metadados do catálogo dataset.created, dataset.removed, dataset.schema_changed, dataset.owner_changed, glossary.term_approved e certification.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, include_auto_monitors: false (sem os monitores automáticos) e cde_only: true (só os alertas dos datasets com um elemento de dados crítico aprovado). 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
datavault.assistant.action_proposed Um agente externo (Claude Code, Cursor, um pipeline) propõe uma ação com um token de API de scope actions:propose (POST /assistant/actions/propose). A proposta fica à espera de uma pessoa em Equipa → Ações da IA: o token nunca a executa. Sai nos canais que recebem alertas, como um aviso warning, com a severidade mínima e o âmbito por ativos do canal (o dataset da proposta, se o tipo tiver). Um canal filtrado por tipos de issue não o recebe, porque não há issue. Um token tem no máximo 20 propostas à espera action_request
datavault.dataset.created Um dataset novo entra no catálogo, pela descoberta de uma conexão ou pelo corpus de documentos de um scan. Só nos canais com dataset.created em event_kinds, como os outros eventos de metadados idempotency_key, metadata_change
datavault.dataset.removed Uma conexão apagada leva os datasets dela. O evento traz o nome, a conexão e os domínios que o dataset tinha idempotency_key, metadata_change
datavault.dataset.schema_changed Um scan ou um monitor de metadados vê colunas novas, removidas ou com outro tipo. Sai uma vez por diff: repetir o scan não o repete idempotency_key, metadata_change
datavault.dataset.owner_changed O owner ou o steward próprio de um dataset muda. user_id a null quer dizer que o dataset volta a herdar o do domínio idempotency_key, metadata_change
datavault.glossary.term_approved Um termo do glossário passa a aprovado, pela API, pelo fluxo de aprovação ou pelo datavault.yaml. Um termo importado do catálogo do cliente não gera o evento, porque foi aprovado lá idempotency_key, metadata_change
datavault.certification.changed A certificação de um dataset ou de um data product muda de estado: pedida, concedida, recusada, revogada ou em revisão (ao expirar ou depois de um scan) idempotency_key, metadata_change

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. Os silêncios não seguram os eventos de metadados: estes dizem o que mudou no catálogo, não como estão os dados.

Eventos de estado (veredito e política):

  • 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_*.

Eventos de metadados (os seis do catálogo):

  • O BeryX grava cada evento na transação da mudança: uma mudança desfeita não deixa evento. O worker entrega-os a cada minuto, em lotes de 50. As mudanças anteriores à versão que trouxe estes eventos não os geram.
  • Uma descoberta ou uma conexão apagada gera no máximo 100 dataset.created ou dataset.removed. O último evento dessa operação traz batch_total (quantos datasets a operação criou ou removeu) e batch_omitted (quantos ficaram sem evento), e o body di-lo. Para os restantes, leia GET /api/v1/datasets?connection_id=….
  • idempotency_key (também no cabeçalho Idempotency-Key) é o sha256 de tipo + sujeito + referência da mudança. É o mesmo em todos os canais e nas novas tentativas. Deduplique por ele.
  • Cada evento sai no máximo uma vez: o worker marca o lote como entregue antes de o enviar. Se um recetor falhar depois das novas tentativas, esse evento e os seguintes do mesmo lote não chegam a esse canal, e a falha fica no last_delivery_status dele. Quem sincroniza um catálogo próprio por estes eventos reconcilia, de tempos a tempos, com a API.
  • O âmbito do canal por datasets, conexões e domínios aplica-se: um dataset passa se ele, a conexão dele ou um dos domínios dele estiver no âmbito. Um termo e a certificação de um data product passam pelo domínio, e um termo sem domínio só chega aos canais sem âmbito por ativos. Num dataset.removed, contam a conexão e os domínios gravados no evento. Um canal de equipa só recebe o que a equipa lê. A severidade mínima e os tipos de issue não contam, e o SMS e o WhatsApp nunca recebem estes eventos.
  • O corpo leva ids, nomes, estados e contagens. Do owner vai só o id da conta (user_id, previous_user_id), nunca o email. O schema traz até 50 nomes de colunas por lista, e *_count dá o total.

O contexto de um datavault.dataset.schema_changed:

{
  "event": "datavault.dataset.schema_changed",
  "idempotency_key": "4b1e…64 hex",
  "metadata_change": {
    "subject_type": "dataset",
    "subject_id": "5d8a2f6c-1e9b-4b7d-a3c5-8f2e6d0b9a71",
    "dataset_id": "5d8a2f6c-1e9b-4b7d-a3c5-8f2e6d0b9a71",
    "dataset_name": "dw.vendas",
    "connection_id": "c2a1e7d4-…",
    "domain_ids": ["9e3f0a1b-…"],
    "scan_id": "e0b4c9a2-…",
    "issue_id": "a4e8c2d6-…",
    "source": "profile",
    "breaking": true,
    "columns_added": ["nif"],
    "columns_added_count": 1,
    "columns_removed": ["telefone"],
    "columns_removed_count": 1,
    "types_changed": [],
    "types_changed_count": 0
  }
}

Os outros eventos de metadados trazem em metadata_change os campos deles: dataset.created traz dataset_id, dataset_name, connection_id e domain_ids. dataset.removed traz esses quatro e reason (connection_deleted). dataset.owner_changed traz role (owner ou steward), previous_user_id e user_id. glossary.term_approved traz term_id, term_name e domain_id. certification.changed traz certification_id, dataset_id ou domain_id (e domain_name), status, action e expires_at. Todos trazem subject_type (dataset, domain ou glossary_term) e subject_id.

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.beryxtech.com/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)
    assistant_action_proposed datavault.assistant.action_proposed request_id, kind
    dataset_created datavault.dataset.created dataset_id; batch_total e batch_omitted no último de uma operação acima do teto
    dataset_removed datavault.dataset.removed dataset_id; batch_total e batch_omitted no último de uma operação acima do teto
    dataset_schema_changed datavault.dataset.schema_changed dataset_id, columns_added_count, columns_removed_count, types_changed_count
    dataset_owner_changed datavault.dataset.owner_changed dataset_id, role
    glossary_term_approved datavault.glossary.term_approved term_id, domain_id (se o termo tiver)
    certification_changed datavault.certification.changed dataset_id ou domain_id, status
  • 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). Um agente travado pela decisão de confiança (perguntou e não leu, consumed: false no POST /trust/consumptions) não conta em exposures_count. 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": {
    "WebhookActionRequest": {
      "additionalProperties": false,
      "description": "Cartão #383: `datavault.assistant.action_proposed` — um agente externo propôs uma\nação com um token `actions:propose`; espera pela decisão de uma pessoa.",
      "properties": {
        "id": {
          "description": "Id do pedido (`GET /assistant/action-requests/{id}`).",
          "title": "Id",
          "type": "string"
        },
        "kind": {
          "description": "Tipo de ação proposto (ex.: `create_ticket`, `issue`).",
          "title": "Kind",
          "type": "string"
        },
        "proposal_id": {
          "title": "Proposal Id",
          "type": "string"
        },
        "origin": {
          "const": "api_token",
          "title": "Origin",
          "type": "string"
        },
        "requested_by": {
          "description": "Quem propôs: «Token de API «<nome>»».",
          "title": "Requested By",
          "type": "string"
        },
        "agent": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "description": "Nome que o agente deu de si, se o enviou.",
          "title": "Agent"
        },
        "dataset_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "description": "Dataset da proposta, se o tipo tiver.",
          "title": "Dataset Id"
        },
        "title": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "description": "Título da proposta (texto do agente, ≤ 200).",
          "title": "Title"
        },
        "expires_at": {
          "description": "Até quando o pedido se pode aprovar (UTC).",
          "format": "date-time",
          "title": "Expires At",
          "type": "string"
        },
        "url": {
          "description": "Onde decidir na app (Equipa → Ações da IA).",
          "title": "Url",
          "type": "string"
        }
      },
      "required": [
        "id",
        "kind",
        "proposal_id",
        "origin",
        "requested_by",
        "agent",
        "dataset_id",
        "title",
        "expires_at",
        "url"
      ],
      "title": "WebhookActionRequest",
      "type": "object"
    },
    "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"
    },
    "WebhookMetadataChange": {
      "additionalProperties": false,
      "description": "Cartão #688: o que mudou no catálogo. Os campos de cada evento vêm só nele; um\ndataset removido traz o nome, a conexão e os domínios que tinha.",
      "properties": {
        "subject_type": {
          "enum": [
            "dataset",
            "domain",
            "glossary_term"
          ],
          "title": "Subject Type",
          "type": "string"
        },
        "subject_id": {
          "description": "O dataset, o domínio ou o termo.",
          "title": "Subject Id",
          "type": "string"
        },
        "dataset_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "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"
        },
        "domain_ids": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Dataset: os domínios (data products) a que pertence.",
          "title": "Domain Ids"
        },
        "domain_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Termo: o domínio dele; certificação de um domínio: o domínio.",
          "title": "Domain Id"
        },
        "domain_name": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Domain Name"
        },
        "reason": {
          "anyOf": [
            {
              "const": "connection_deleted",
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Só em datavault.dataset.removed.",
          "title": "Reason"
        },
        "batch_total": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Só no último evento de uma descoberta ou de uma conexão apagada acima do teto (100): quantos datasets a operação criou ou removeu.",
          "title": "Batch Total"
        },
        "batch_omitted": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Com `batch_total`: quantos ficaram sem evento.",
          "title": "Batch Omitted"
        },
        "scan_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Schema: o scan que viu o diff.",
          "title": "Scan Id"
        },
        "issue_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Schema: o issue `schema_change`.",
          "title": "Issue Id"
        },
        "source": {
          "anyOf": [
            {
              "enum": [
                "profile",
                "metadata",
                "contract"
              ],
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Schema: quem viu o diff — `profile` (o scan), `metadata` (o monitor de metadados) ou `contract` (o contrato de uma API).",
          "title": "Source"
        },
        "breaking": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Schema: o diff parte quem lê.",
          "title": "Breaking"
        },
        "columns_added": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Schema: até 50 nomes.",
          "title": "Columns Added"
        },
        "columns_added_count": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Columns Added Count"
        },
        "columns_removed": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Columns Removed"
        },
        "columns_removed_count": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Columns Removed Count"
        },
        "types_changed": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Types Changed"
        },
        "types_changed_count": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Types Changed Count"
        },
        "role": {
          "anyOf": [
            {
              "enum": [
                "owner",
                "steward"
              ],
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Owner: o papel que mudou.",
          "title": "Role"
        },
        "previous_user_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Owner: a conta de antes.",
          "title": "Previous User Id"
        },
        "user_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Owner: a conta nova; null = volta a herdar do domínio.",
          "title": "User Id"
        },
        "term_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Term Id"
        },
        "term_name": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Term Name"
        },
        "certification_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Certification Id"
        },
        "status": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Certificação: requested, certified, in_review, revoked ou rejected.",
          "title": "Status"
        },
        "action": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Certificação: a transição (ex.: `certification_granted`).",
          "title": "Action"
        },
        "expires_at": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Certificação: fim da validade.",
          "title": "Expires At"
        }
      },
      "required": [
        "subject_type",
        "subject_id"
      ],
      "title": "WebhookMetadataChange",
      "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-BeryX-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",
        "datavault.assistant.action_proposed",
        "datavault.dataset.created",
        "datavault.dataset.removed",
        "datavault.dataset.schema_changed",
        "datavault.dataset.owner_changed",
        "datavault.glossary.term_approved",
        "datavault.certification.changed"
      ],
      "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."
    },
    "action_request": {
      "anyOf": [
        {
          "$ref": "#/$defs/WebhookActionRequest"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Só em datavault.assistant.action_proposed."
    },
    "metadata_change": {
      "anyOf": [
        {
          "$ref": "#/$defs/WebhookMetadataChange"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Só nos eventos de metadados do catálogo (datavault.dataset.created, …removed, …schema_changed, …owner_changed, datavault.glossary.term_approved e datavault.certification.changed)."
    }
  },
  "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.beryxtech.com/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. O ServiceNow (cartão #255) também comenta e transita, mas o estado dele chega só pelo polling: o PUT …/sync recusa (400) um webhook_secret e o webhook_path vem nulo. 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 (e, sem os webhooks, do ServiceNow). Nesses três, 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.