BeryX outbound webhooks
For anyone integrating BeryX alerts into their stack (SIEM, PagerDuty, Opsgenie, their own automations). Contract for the "Generic webhook" channel on the Notifications page: events, JSON body, headers, HMAC signature, retry and how to test. Card #323. Slack, Microsoft Teams and email use those services' formats and are not covered here.
Summary
- Each delivery is an HTTPS
POSTwith a JSON body (Content-Type: application/json) and four headers:X-BeryX-Event,X-BeryX-Delivery,X-BeryX-TimestampandX-BeryX-Signature. - The signature is
v1=followed by the HMAC-SHA256 in hexadecimal, computed with the channel's secret over"{timestamp}.{corpo}". The receiver recomputes it over the raw bytes of the body and rejects timestamps that differ by more than 5 minutes. That way, a captured delivery cannot be replayed. - Reply with a
2xxin under 10 seconds. A5xx, a timeout or a refused connection lead to up to 3 new attempts (1 s, 5 s and 25 s later). A4xxis the final response. - The same
X-BeryX-Deliveryis repeated in every attempt. Use it to deduplicate.
Configure the channel
- On the Notifications page, "New channel" → type Generic webhook, with your receiver's
https://URL, the minimum severity and, if you want, the weekly digest. The host must be public: a URL that points to an internal or private address is refused on save (see Refused destinations). - The creation response shows the signing secret (
whsec_…) only once. Store it in the receiver's secrets vault. BeryX stores it encrypted (Fernet), inside the channel's configuration, and never returns it again: not in the listing, not in the logs, not in the configuration-as-code export. - Click "Test". The test delivery is signed and the result shows the HTTP code that the receiver returned (see Test the channel).
Through the API, with a workspace admin or member session:
| Operation | Request | Notes |
|---|---|---|
| Create | POST /api/v1/notification-channels {"name", "kind": "webhook", "config": {"webhook_url": "https://…"}, "min_severity", "digest"} |
The response includes signing_secret (only here). A signing_secret sent in config is ignored: the secret is always generated by the server |
| List | GET /api/v1/notification-channels |
signed (the channel signs), last_delivery_status (delivered | failed | skipped), last_delivery_at, last_delivery_error (HTTP 502, timeout…) |
| Rotate the secret | POST /api/v1/notification-channels/{id}/rotate-secret |
Admins only. Returns {"signing_secret"}, once |
| Test | POST /api/v1/notification-channels/{id}/test |
One attempt, no retry. {ok, message, status_code, delivery_id, signed} |
With restricted-access domains in the workspace, only an admin creates or changes (PATCH) a
channel without a team, because it receives the alerts of every dataset. Someone who is not an
admin creates or changes the channel of a team that reads nothing they cannot read, such as a team
they belong to. Anything else gets a 403. In a PATCH, both the channel as it is and as it will
be count. Testing (POST /api/v1/notification-channels/{id}/test) and deleting
(DELETE /api/v1/notification-channels/{id}) follow the same rule, for the channel as it is.
The events (event_kinds, optional on creation and in PATCH) say what the channel receives:
alerts (alerts and escalation, the default value) and, optionally, the state events
dataset.verdict_changed, policy.violation_opened, policy.violation_resolved and
domain.verdict_changed (see Events). Without alerts, the channel stops receiving
alerts. On the Notifications page it is the "Events" button on each channel. In datavault.yaml
it is channels[].event_kinds and in IaC (/iac/notification-channels) it is event_kinds. In
both, a channel without the field keeps what it has.
The scope (scope, optional on creation and in PATCH) limits the alerts that reach the
channel: domain_ids, connection_ids and dataset_ids (the channel follows any of them),
issue_types and include_auto_monitors: false (without the automatic monitors). Without a
scope, the channel receives everything that passes the minimum severity.
GET /api/v1/notification-channels/{id}/preview?days=30 shows how many alerts from the last few
days would have reached the channel.
Changing the URL (PATCH or PUT /iac/notification-channels/{id}) keeps the secret. A
channel created through configuration as code (/iac, Terraform provider) is also created with a
secret, but that secret is not returned. To get it, rotate it once.
Events
event |
When it is sent | Context fields |
|---|---|---|
datavault.alerts |
At the end of each scan with new warning/critical alerts above the channel's minimum severity and within its scope, if it has one. Alerts from the same incident (cascade through lineage) come aggregated. It is also sent, regardless of the minimum severity and the scope, on a channel registered as a consumer of a data product, when that domain's SLA is breached with critical severity: one delivery per domain with the new breaches, without repeating those already notified on that channel |
alerts[], incidents[] |
datavault.escalation |
A problem remains unacknowledged and the on-call escalation moves up a level, or is exhausted. It also respects the channel's scope | escalation |
datavault.issue.sla_breached |
A critical or warning problem goes past its severity's resolution target (the SLA in the escalation policy). Sent once per problem, with the channel's minimum severity and scope |
sla_breach |
datavault.digest |
Weekly summary, on channels with the "Weekly" digest | none |
datavault.steward_plan |
The steward's plan for the day, on the channels chosen in the steward policy | turn_id, report_id, period_key, counts |
datavault.test |
"Test" on the Notifications page (or POST …/test) |
none |
datavault.dataset.verdict_changed |
At the end of a scan, when the dataset's verdict (with a policy, the one from GET /datasets/{id}/verdict) differs from the previous scan. Only on channels with dataset.verdict_changed in event_kinds |
idempotency_key, verdict_change |
datavault.policy.violation_opened |
At the end of a scan, when a data policy rule starts being violated on the dataset | idempotency_key, policy_violation |
datavault.policy.violation_resolved |
At the end of a scan, when a rule stops being violated (or was turned off) | idempotency_key, policy_violation |
datavault.domain.verdict_changed |
At the end of a scan, when the aggregated verdict of a domain (data product, GET /domains/{id}/verdict) changes because of the datasets that changed |
idempotency_key, verdict_change |
datavault.consumer.untrusted_read |
An agent or application records through the Trust API (POST /trust/consumptions) that it read datasets with the untrusted decision. It is sent as a warning notice on the channels that receive alerts, with the channel's minimum severity and asset scope (only the datasets in scope). A channel filtered by issue types does not receive it, because there is no issue. A replay of the same run_ref does not repeat the notice, and at most one notice per hour is sent per consumer and dataset, even with a different run_ref. The workspace also has a cap of 20 notices per hour: the notice that reaches it says so in the body, and the following ones stay only in the in-app bell |
consumer_read |
The same value goes in the X-BeryX-Event header, so you can route the delivery without
reading the body. Ignore the events you do not know.
An active alert mute (Notifications → "Active mutes") holds back the deliveries it covers,
and the scans keep measuring. A muted problem is not sent in datavault.alerts (nor in the
notice to consumers), datavault.escalation or datavault.issue.sla_breached. The state events of
a muted dataset, or of its connection, are not sent either. A mute for only some problem
types holds back the policy events when it includes policy_violation, and the verdict is still
sent. A domain's verdict is only held back when the whole workspace is muted. The alerts and
state events held back stay in the in-app bell.
State events (the last four):
- They are sent only when the state changes. Without a verdict change there is no
verdict_changed. BeryX stores the verdict of each dataset at the end of each scan, whether or not any channel asks for events. The first snapshot of a dataset (a new dataset, or the first scan since the version with state events went into production) is the baseline and does not generate events. For the current state, useGET /api/v1/datasets/{id}/verdict. - One event is sent per delivery. A scan that changes the verdict of a dataset in a domain gives
one
datavault.dataset.verdict_changedand onedatavault.domain.verdict_changed. idempotency_key(also in theIdempotency-Keyheader) is the sha256 of subject + scan + type (+ rule). It is the same on every channel and in a repeat of the same scan. Deduplicate by it.- The channel's scope by domains, connections and datasets also applies. The minimum severity and the issue types only filter alerts.
trust_urlpoints to the trust facts in the API (GET …/trust-facts), which require a session or advk_*token.
Up to version 1.29.0 of the API, the weekly digest arrived as datavault.alerts with empty lists.
Body
Full example of a datavault.alerts, on a channel in English:
{
"event": "datavault.alerts",
"workspace_id": "6f1c2a9e-3d4b-4c8a-9e2f-1a7b5c3d9e10",
"occurred_at": "2026-10-06T08:15:42.118230Z",
"title": "BeryX: 1 new data quality alert",
"body": "• [CRITICAL] Nulos em clientes.nif após o load de vendas (dw.clientes → dw.vendas)\n ↳ Impact: 3 downstream datasets (2 through column 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
}
]
}
And the fields of a datavault.dataset.verdict_changed (the title and the body follow the
pattern of the other events):
{
"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"
}
}
In a datavault.domain.verdict_changed, verdict_change carries domain_id, domain_name,
datasets_changed, from, to, scan_id and trust_url. In a
datavault.policy.violation_opened or _resolved, policy_violation carries dataset_id,
dataset_name, connection_id, scan_id, rule, label, severity, source (workspace or
domain:<nome>), source_domain, verdict, score and trust_url.
And the context of a datavault.escalation:
{"escalation": {"issue_id": "a4e8c2d6-9f13-4b7a-8c5e-2d1f6b9a3e47", "level": 1, "user_id": "3b7e…", "exhausted": false}}
And that of a 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"}}
titleandbodyare human-readable text, ready to display, in the channel's language: thelocalein the channel'sconfig, if there is one, otherwise the workspace's default language (default_locale, set withPUT /api/v1/workspace/locale), otherwise Portuguese. The title of each alert and of each incident stays as the scan recorded it.titleandbodyare plain text, not escaped, and carry names that users choose (datasets, rules, issues, teams). Whoever shows them in a chat or on a page escapes the target's markup, as BeryX's Slack and Teams channels do (app/domain/alerts/chat_markup.py): a name with<!channel>or[texto](url)must not turn into a mention or a link.codeandparamssay the same without a language and come in every event since version 1.73.0 of the API.codeis a stable code in English and snake_case, andparamscarries only numbers and identifiers. To automate, use them with the structured fields and the ids, and fetch the details through the API (GET /api/v1/issues/{id}). The codes:codeeventparamsalerts_newdatavault.alertscount(incidents and standalone alerts in the delivery),alerts,incidentsconsumer_sla_breacheddatavault.alerts, in the notice to the consumers of a data productdomain_id,issuesescalation_leveldatavault.escalationissue_id,level,minutes_unackedescalation_exhausteddatavault.escalation, with the rotation exhaustedissue_id,level,minutes_unackedescalation_teamdatavault.escalation, when the exhausted rotation goes up to a teamissue_id,level,minutes_unacked,team_idissue_sla_breacheddatavault.issue.sla_breachedissue_id,target_hoursdigest_weeklydatavault.digesthealthy,warning,critical,new_signals,expiring_tokens,steward_proposalsdigest_team_weeklydatavault.digest, on a team's channelteam_id,open,critical,warning,acknowledged,overdue,resolvedsteward_plandatavault.steward_plantotal,high,medium,low,suggestions_createdchannel_testdatavault.testnone dataset_verdict_changeddatavault.dataset.verdict_changeddataset_id,from,to,score(if there is one)domain_verdict_changeddatavault.domain.verdict_changeddomain_id,from,topolicy_violation_openeddatavault.policy.violation_openeddataset_id,rule,severity,verdictpolicy_violation_resolveddatavault.policy.violation_resolveddataset_id,rule,severity,verdictconsumer_untrusted_readdatavault.consumer.untrusted_readconsumer_id,consumer_kind,datasets,warnings_cap(only in the notice that reaches the cap)The body never carries values from your data sources or samples: only ids, titles, severities and counts.
alertsandincidentsare always present, empty outsidedatavault.alerts. The context fields are only present in the corresponding event.Each incident carries its blast radius.
impact_scopeiscolumnwhen the root's problems are about a column (impact_columns) and the views' column-level lineage says which downstream datasets use it,tablewhen the impact stays at table level (thebodysays «linhagem por coluna indisponível», "column-level lineage unavailable", if the problems were about a column but there was no such map) andnonewhen nothing downstream is affected.downstream_countcounts the downstream datasets,downstream_column_countthose affected through the root's columns andexposures_countthe consumers (dbt exposures and registered agents).impact_scopeand the three counts arenull(andimpact_columnsempty) if the impact could not be computed for that delivery.New fields may appear in a minor version. Do not reject a body for having extra fields.
JSON Schema
Generated from the Pydantic model WebhookPayload (backend/app/api/v1/schemas.py) by
python scripts/webhook_schema.py build. A test fails if this block becomes outdated.
{
"$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"
}
Headers
| Header | Example | Purpose |
|---|---|---|
X-BeryX-Event |
datavault.alerts |
Event type (same as event in the body) |
X-BeryX-Delivery |
5f0c9a1e-… (UUID) |
Delivery id. It is the same in every attempt: deduplicate by it |
X-BeryX-Timestamp |
1791274542 |
Unix seconds when this attempt was sent. It is part of the signature |
X-BeryX-Signature |
v1=7b4fddea… |
HMAC-SHA256 in hex of "{timestamp}.{corpo}" with the channel's secret |
Idempotency-Key |
9f2c… (64 hex) |
Only on state events: same as idempotency_key in the body, the same on every channel and in a repeat of the scan |
X-BeryX-Signature may carry several comma-separated signatures (v1=…,v1=…), for
example in a future rotation window with two active secrets. Accept the delivery if any of
them matches. Channels created before card #323 have no secret and send without
X-BeryX-Signature until an admin rotates the secret. The other three headers are always sent.
Until 31 May 2027, each of the four X-BeryX-* headers is also sent under its old name,
X-DataVault-*, with the same value. The signature covers "{timestamp}.{corpo}", not the
header name, so a receiver that still reads X-DataVault-Signature keeps validating
deliveries. Move your receiver to the X-BeryX-* headers before that date.
Verify the signature
Three rules:
- Compute the HMAC over the raw bytes of the body, before any parsing. Re-serialising the JSON changes whitespace and key order, and the signature no longer matches.
- Compare in constant time (
hmac.compare_digest,crypto.timingSafeEqual). - Reject timestamps more than 300 seconds away from your clock. This is what stops the replay of a captured delivery. Keep the receiver's clock synchronised via NTP.
Test vector: secret whsec_test, timestamp 1700000000 and body {"event":"datavault.test"}
give v1=7b4fddeaeedd31820bf0f0affa3881d841e08efaedff83f5b685059373143810.
Python (standard library):
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 # outside the window: replay or wrong clock
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 is the body's Buffer (for example, 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));
}
A complete, runnable receiver in FastAPI (verification, 5-minute window and deduplication
by X-BeryX-Delivery) is in backend/scripts/webhook_receiver.py:
pip install fastapi uvicorn
BERYX_WEBHOOK_SECRET=whsec_… uvicorn webhook_receiver:app --port 9000
The same file serves as the reference receiver in the product's tests (backend/tests/test_webhooks.py).
Retry and duplicate deliveries
| Receiver's response | What BeryX does |
|---|---|
2xx |
Delivered. last_delivery_status = delivered |
5xx, timeout (10 s), connection refused or dropped |
New attempt 1 s, 5 s and 25 s after the previous one: at most 4 sends in ~70 s |
4xx (including 401/403 from a rejected signature, and 429) |
Final response, no new attempt. last_delivery_status = failed |
3xx |
Redirects are not followed: it counts as a failure. Configure the final URL |
| The URL's host resolves to an internal or private address | Destination refused (anti-SSRF protection), no new attempt. last_delivery_error = destino recusado (SSRF) |
- Each attempt carries a new
X-BeryX-Timestampand signature (the 5-minute window counts from the actual send). TheX-BeryX-Deliveryis always the same. - A response lost on the way back can make the same delivery arrive twice. Deduplicate by
X-BeryX-Delivery: if you have already processed it, reply200and do nothing. - Once the attempts are exhausted, the delivery is marked as failed: there is no persisted queue
and no later resend. The failure is visible on the channel card ("Last delivery: failed
(HTTP 502)") and in
GET /notification-channels, and the alert remains available in the app. - Reply quickly (
202and asynchronous processing on your side). A slow receiver delays the following deliveries to the other channels in the same workspace.
Test the channel
"Test" (or POST /api/v1/notification-channels/{id}/test) sends a signed datavault.test,
in a single attempt, and shows the result of the verification done by your receiver:
| Result | Meaning |
|---|---|
| "Signed delivery accepted by the receiver (HTTP 200)" | The receiver validated the signature and replied 2xx |
| "The receiver rejected the signature (HTTP 401)" | Verification failed on the receiver's side: the secret is not the current one (was it rotated?), or the HMAC is computed over the re-serialised JSON instead of the raw bytes |
| "Delivery accepted, but unsigned" | Channel created before card #323: rotate the secret to turn signing on |
| "Delivery failed: HTTP 5xx / timeout" | The receiver did not respond or returned an error. In production there would be a retry |
| "Delivery failed: destination refused (SSRF)" | The URL's host now resolves to an internal or private address. Nothing was sent (see Refused destinations) |
The result also appears under "Last delivery" on the channel card.
Rotate the secret
"Rotate secret" (admins only), or POST …/rotate-secret, generates a new secret and shows it
once. The switch is immediate: the following deliveries are already signed with the new one.
To avoid losing deliveries during the switch, prepare the receiver to accept the old secret
and the new one for a few minutes. After rotating, update it and remove the old one. The
rotation is recorded in the Audit log ("Webhook secret rotated").
If the secret leaks, rotate it straight away. Anyone who has it can forge deliveries to your receiver.
Refused destinations
The webhook URL comes from the workspace configuration, so it cannot point inside BeryX's infrastructure (anti-SSRF protection, SEGURANCA.md):
- The host must resolve only to public addresses. A loopback, link-local (including the cloud
metadata,
169.254.169.254andmetadata.google.internal) or private-network IP is refused, as is a host that does not resolve. - Validation runs when the channel is saved (in the app and in the IaC API,
400/422with the reason) and again on every send and every retry attempt, with the connection pinned to the validated IP. If the host's DNS changes to an internal address after saving, the send is refused. - A refused delivery is not retried and shows under "Last delivery" as "destination refused (SSRF)".
- On a dedicated instance with the receiver in the customer's network, the operator can exempt that
host in
DATAVAULT_CONNECTOR_ALLOWED_HOSTS.httpsis still mandatory.
The webhook URL is treated as a secret: it does not appear in the "Test" response, in
last_delivery_error or in BeryX's logs, which keep only the HTTP code or the error type.
Egress IPs
Webhooks leave from BeryX's infrastructure on Google Cloud (europe-west1). The fixed egress
IP, shared per region, is prepared but still not yet active in production (#248, see
REDE_ALLOWLIST.md). Until then, do not filter the receiver by source IP: use
the signature. After activation, the IP in force appears at
GET https://api.praxis.lbostuff.tech/api/v1/public/egress-ips. You can allow it as an
additional layer, never instead of the signature.
Known limitations
- No persisted queue and no manual resend of old deliveries. The delivery history keeps
only the last one (
last_delivery_*). - State events start at the first snapshot of each dataset: what changed before that is not resent.
Inbound ticket webhooks (Jira, Linear)
The reverse direction, card #381: Jira or Linear tell BeryX when a ticket linked to an
issue changes status. They are optional. Without them, BeryX reads the status of each
open, in_progress or unknown ticket, and of each done ticket whose issue was reopened,
every 15 minutes (polling); with them, the change applies as soon as the event arrives.
- Turn on: in
PUT /api/v1/notification-channels/tickets/{integration_id}/sync(admin), setwebhook_secret(16 characters or more). The response carrieswebhook_path, the path to register with the provider:POST /api/v1/webhooks/tickets/{integration_id}. An emptywebhook_secretturns them off. - Signature: Jira sends
X-Hub-Signature: sha256=<hex>and LinearLinear-Signature: <hex>, both an HMAC-SHA256 of the raw body with the secret. BeryX checks it before parsing the JSON. An invalid signature, or an integration without a secret, gets401and changes nothing. An event without the provider's timestamp (Jiratimestamp, LinearwebhookTimestamp), or more than 5 minutes off, gets400: a captured signed body stops being valid. - Limits: 120 requests per minute per integration and IP (
429withRetry-After) and a body of up to 1 MB (413). The anti-SSRF guard does not apply: BeryX only receives. - Response:
200withstatusset toapplied,unchanged,ignored(an event that does not change status),unknown_ticketorworkspace_disabled. Deliveries about a linked ticket (applied,unchanged) and those for a suspended workspace go to the audit trail (ticket_webhook_received) with the provider, key, status and outcome, never the body. The others (ignored,unknown_ticket, for example from a broad Jira filter) only go to the log.
Status is read from the category (Jira statusCategory, Linear state.type), not from the
names each team gives its columns. A closed ticket (done) on an open issue resolves it. If
the issue comes from a rule, it waits for verification: the next scan that evaluates the rule
confirms it (it stays resolved) or reopens it. Every change records an event in the issue's
history with the origin ticket:<provider>:<key>. In the other direction, with comment on
(the default), BeryX comments on the ticket when the issue is resolved, verified, reopened
or ignored. It only says "verified" or "verification failed" when the issue's status is the one
the scan left, and gives the key of the ticket that was closed. If someone resolves or reopens
the issue in BeryX before the comment goes out, the comment only says it was resolved or
reopened. With transition, it also closes the ticket if it is not closed yet, or reopens it if
it is. Comments and transitions go out with the integration's credentials, and any member with
write access can link a ticket by hand. So they only reach tickets in the project (Jira) or team
(Linear) configured on the integration; for Linear, the team is read from the provider before
writing. A ticket from another project or team that is already linked to the integration is
still read, but receives nothing. When linking a ticket (POST /api/v1/tickets/link) to a Jira
or Linear integration, the given key must be the one in the URL, when the URL has one (422).
For Jira, a key outside the project of the integration chosen in integration_id gets 422;
without integration_id, as in the app, the ticket is linked without an integration and stays
out of the sync. interval_min (5 to 1440) changes the polling interval. A ticket that fails on its own
(deleted at the provider, invalid key) goes to the back of the queue and the others carry on; a
notice it cannot receive is dropped. A provider that answers 429, rejects the credentials or
is down pushes the integration back, with the wait doubling up to 6 hours. For ServiceNow,
Azure DevOps, GitHub and GitLab, polling reads the ticket status; comments, transitions and
inbound webhooks are, for now, Jira and Linear only. For those four, PUT …/sync refuses
(400) comment or transition set to true and a webhook_secret, the policy returns them
as false and webhook_path is null.