The `obserae.alert/2` contract

Every outbound alert is one JSON document, identified by its own schema_version. This section is the consumer’s view of it; the operator’s view — how to create an output, route it and choose its format — is in Outputs.

The JSON Schema is served by your own instance at GET /api/schemas/alert/2. Validate against that rather than against a copy: it is the contract the daemon you are connected to actually speaks, at the version it is running, and an air-gapped instance can hand it over with no outbound access.

The schema declares an $id of https://obserae.com/schemas/alert-2.json. That is a JSON Schema identifier, which is what $id is for — it names the schema, it is not a download location. Nothing is published at that address; fetch the schema from your instance.

A complete example

{
  "schema_version": "obserae.alert/2",
  "event_type": "alert.fired",
  "id": "018f1c00-0000-7000-8000-000000000001",
  "dedup_key": "a41f9c7e2b…",
  "fired_at": "2026-06-04T12:00:00Z",
  "sent_at": "2026-06-04T12:00:01Z",

  "instance": {
    "id": "8f2c0f1e-4a7b-4c2d-9e10-3b5a6c7d8e90",
    "name": "obserae-paris-dc1",
    "version": "0.34.0",
    "base_url": "https://obserae.corp.example"
  },

  "rule": {
    "id": "3f5b0c4e-2d19-4a63-b8c7-1e5f9a0d4c22",
    "name": "postgres-from-build",
    "type": "query",
    "severity": "high",
    "severity_score": 70,
    "tags": ["prod", "segmentation"],
    "url": "https://obserae.corp.example/alerting/rules/3f5b0c4e-…"
  },

  "alert": {
    "status": "open",
    "matched_count": 7,
    "first_seen_at": "2026-06-04T11:58:00Z",
    "url": "https://obserae.corp.example/alerts/018f1c00-…"
  },

  "query": {
    "nfql": "FROM sessions | LAST 300 | WHERE server_port == 5432 | KEEP client_ip, server_ip, server_port",
    "as_of": "2026-06-04T12:00:00Z"
  },

  "matches": {
    "columns": [
      {"name": "client_ip", "type": "VARCHAR"},
      {"name": "server_ip", "type": "VARCHAR"},
      {"name": "server_port", "type": "INTEGER"}
    ],
    "rows": [["10.0.0.50", "10.20.4.11", 5432]],
    "records": [{"client_ip": "10.0.0.50", "server_ip": "10.20.4.11", "server_port": 5432}],
    "sample_size": 1,
    "truncated": true
  },

  "entities": [
    {
      "ip": "10.0.0.50",
      "role": "client",
      "scope": "internal",
      "carto": {"resolved": true, "host": "ci-runner-prod", "group": "build", "network": "build"},
      "policy": {"covered": false, "matched_rule": null,
                 "checked_pair": "10.0.0.50 → 10.20.4.11:5432/tcp"}
    },
    {
      "ip": "10.20.4.11",
      "role": "server",
      "scope": "internal",
      "carto": {"resolved": true, "host": "db-01", "network": "db"},
      "service": {"name": "postgresql", "port": 5432, "protocol": "tcp"}
    }
  ]
}

Field by field

Everything below is at the top level of the document unless the path says otherwise. Always means the key is present in every alert.fired; conditional means the key is absent, not empty, when it does not apply — obserae never emits "host": "" to mean “unknown”.

FieldPresenceWhat a playbook does with it
schema_versionalwaysRoute on it. obserae.alert/2 today.
event_typealwaysalert.fired, alert.resolved, alert.acknowledged or test. Branch here first.
idalwaysThe occurrence. Unique per fire; use it for idempotency.
dedup_keyalwaysThe incident. Stable across renames; open your ticket on it.
fired_atalwaysRFC3339 UTC, when the rule matched. Use it as as_of on every call back.
sent_atalwaysWhen this delivery attempt left. Differs from fired_at on a retry.
instance.idalwaysWhich obserae. The discriminator in a multi-site (MSSP) SOAR.
instance.name / .versionalwaysHuman labels for the ticket.
instance.base_urlconditionalAbsent unless Public URL is configured.
rule.idalwaysCorrelate on this. Immutable.
rule.namealwaysA label your colleagues will edit. Display it; never key on it.
rule.typealwaysquery, threshold, group_by, heartbeat, anomaly, first_seen, presence.
rule.severityalwaysinfo, low, medium, high, critical.
rule.severity_scorealways10/30/50/70/90. Map this to your own scale — it is arithmetic, not a string table.
rule.tagsalwaysArray, possibly empty. Route on it.
alert.statusalwaysopen, acknowledged, closed.
alert.matched_countalwaysThe real total, which is not matches.sample_size.
alert.first_seen_atalwaysWhen this incident opened. For an hourly re-fire it is not fired_at.
alert.observed_value / .thresholdconditionalThreshold rules only.
alert.keyconditionalGroup-by rules only: the entity this alert is about.
query.nfql / query.as_ofalwaysReplay the alert. Post them to /api/query.
matches.columnsalways{name, type} per column, in row order.
matches.rowsalwaysPositional. Kept for compactness; do not address by index.
matches.recordsalwaysRead this one. The rows with names applied.
matches.sample_sizealwaysHow many rows travelled.
matches.truncatedalwaystrue when matched_count exceeds the sample.
entities[]conditionalAbsent when entity context is off. See below.
*.urlconditionalPresent only with Public URL set, and never carrying a token.

Two shapes deviate, deliberately:

  • A heartbeat rule fires because its query went silent, so matches.rows is [] while matches.columns still says what it was looking for.
  • An alert.resolved event carries no matches block at all — there is nothing left to show. It brings resolved_at, duration_seconds and fire_count, and the same dedup_key as the fire it closes.

The entities block

This is the part that removes the follow-up calls. Each address found in the matched rows is resolved once, at fire time, and travels with the alert:

{
  "ip": "198.51.100.7",
  "role": "server",
  "scope": "external",
  "public": {"asn": 15169, "as_org": "Google LLC", "country": "US",
             "cloud": {"provider": "gcp", "region": "us-central1"}},
  "threat_intel": [
    {"source": "tor", "verdict": "match", "list": "exit-nodes",
     "updated_at": "2026-06-04T06:00:00Z"}
  ],
  "policy": {"covered": false, "matched_rule": null,
             "checked_pair": "10.0.0.50 → 198.51.100.7:4444/tcp"},
  "first_seen": "2026-05-30T09:12:00Z",
  "last_seen": "2026-06-04T12:00:00Z"
}

Read three of these carefully, because they encode “we don’t know” differently on purpose:

  • carto.resolved: false — the address is not in your cartography. No host key is emitted alongside it.
  • threat_intel: [] — checked, and no feed lists it. An absent threat_intel means the check did not apply; a single entry with verdict: "unavailable" means the feed database could not be consulted. Three different answers, three different shapes.
  • "enrichment": "timeout" on an entity — enrichment is capped at 250 ms per alert and this one did not finish. The alert was sent anyway. Nothing about enrichment ever delays or blocks a notification.

The block is bounded: at most outputs.max_entities (default 20) entities, most frequent addresses first, with entities_truncated: true when it was cut. Turn it down to carto (asset resolution only) or off in the output settings — see Choosing how much context to send.

What leaves the instance

The question a security officer asks before approving any of this. obserae sends nothing anywhere unless you create an output destination; there is no telemetry, no online license check and no phone-home. Once you create one, exactly this leaves, and nothing else:

DirectionWhat crosses the boundary
Outbound webhook / SIEM output (v2)The document above: rule metadata, the sampled matched rows (10 by default, outputs.sample_rows), and the entity context. That means internal IP addresses, hostnames and group names from your cartography.
Outbound chat / on-call outputA rendered message: rule name, severity, counts, and a link if Public URL is set. No row sample beyond what the message shows.
Outbound ECS / OCSF / CIM projectionThe same facts, renamed onto the target schema. A projection never adds a field obserae did not already have.
Inbound APINothing. It is pull-only: your orchestrator asks, obserae answers, obserae initiates no connection.
GET /api/indicatorsPublic addresses only. RFC1918 addresses are never published — a firewall acting on a list containing your own subnets would be blocking you.

No *.url field ever carries a token or an authentication parameter. Every credential you configure — signing secrets, output tokens — is encrypted at rest and never readable back through the API.