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”.
| Field | Presence | What a playbook does with it |
|---|---|---|
schema_version | always | Route on it. obserae.alert/2 today. |
event_type | always | alert.fired, alert.resolved, alert.acknowledged or test. Branch here first. |
id | always | The occurrence. Unique per fire; use it for idempotency. |
dedup_key | always | The incident. Stable across renames; open your ticket on it. |
fired_at | always | RFC3339 UTC, when the rule matched. Use it as as_of on every call back. |
sent_at | always | When this delivery attempt left. Differs from fired_at on a retry. |
instance.id | always | Which obserae. The discriminator in a multi-site (MSSP) SOAR. |
instance.name / .version | always | Human labels for the ticket. |
instance.base_url | conditional | Absent unless Public URL is configured. |
rule.id | always | Correlate on this. Immutable. |
rule.name | always | A label your colleagues will edit. Display it; never key on it. |
rule.type | always | query, threshold, group_by, heartbeat, anomaly, first_seen, presence. |
rule.severity | always | info, low, medium, high, critical. |
rule.severity_score | always | 10/30/50/70/90. Map this to your own scale — it is arithmetic, not a string table. |
rule.tags | always | Array, possibly empty. Route on it. |
alert.status | always | open, acknowledged, closed. |
alert.matched_count | always | The real total, which is not matches.sample_size. |
alert.first_seen_at | always | When this incident opened. For an hourly re-fire it is not fired_at. |
alert.observed_value / .threshold | conditional | Threshold rules only. |
alert.key | conditional | Group-by rules only: the entity this alert is about. |
query.nfql / query.as_of | always | Replay the alert. Post them to /api/query. |
matches.columns | always | {name, type} per column, in row order. |
matches.rows | always | Positional. Kept for compactness; do not address by index. |
matches.records | always | Read this one. The rows with names applied. |
matches.sample_size | always | How many rows travelled. |
matches.truncated | always | true when matched_count exceeds the sample. |
entities[] | conditional | Absent when entity context is off. See below. |
*.url | conditional | Present 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.rowsis[]whilematches.columnsstill says what it was looking for. - An
alert.resolvedevent carries nomatchesblock at all — there is nothing left to show. It bringsresolved_at,duration_secondsandfire_count, and the samededup_keyas 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. Nohostkey is emitted alongside it.threat_intel: []— checked, and no feed lists it. An absentthreat_intelmeans the check did not apply; a single entry withverdict: "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:
| Direction | What 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 output | A 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 projection | The same facts, renamed onto the target schema. A projection never adds a field obserae did not already have. |
| Inbound API | Nothing. It is pull-only: your orchestrator asks, obserae answers, obserae initiates no connection. |
GET /api/indicators | Public 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.