REST API
obserae exposes an HTTP API you can drive from scripts, supervision systems and
CI. It is the same server that renders the Web GUI: the browser
pages return HTML, while every path under /api/… returns JSON and every
path under /ws/… is a WebSocket. This page is the reference for the JSON
API — how to authenticate, what each endpoint does, and copy-paste curl
examples.
- Base URL — the web server binds
127.0.0.1:8080by default (web.bindin configuration). All examples below usehttp://127.0.0.1:8080; substitute your host and port. - Content type — send
Content-Type: application/jsonon requests with a body; responses are alwaysapplication/json. - Read vs. write — read endpoints (
GET) run on a dedicated reader pool and never block ingestion; mutations (POST/PATCH/DELETE) serialise on the writer. - Machine-readable spec — a full OpenAPI 3.1 description of every
endpoint ships alongside this page as
openapi.yaml. Import it into Postman/Insomnia, generate a client, or render it with Swagger UI / Redoc. Building a SOAR connector? Useopenapi-soar.yamlinstead — see the SOAR profile below, and Response Integrations for the whole picture: the alert contract, the signature, and three worked playbooks.
Authentication
Every /api/… endpoint requires an API token, sent as a Bearer credential:
Authorization: Bearer obs_…
The only exceptions are the two public probes documented below
(GET /healthz and GET /api/protocols).
Minting a token
A token is minted for a user and inherits that user’s permissions. The secret is shown once at creation — copy it then; obserae only stores a hash.
In the GUI — Identity & Access → Users → + New token. See Web GUI › API tokens.
From the CLI — over the admin socket (the secret is printed once):
obserae-cli user token-add --name ci --user alice obserae-cli user token-ls obserae-cli user token-rm <TOKEN_ID> # revoke
Give the token’s owner the least privilege it needs. A supervision system
that only polls /api/status, for example, should own a token whose user is in
the built-in monitoring group (just monitoring:read) and nothing else.
No CSRF, no MFA for tokens
Bearer-token clients are exempt from CSRF and from the MFA enrollment gate — a script never needs a CSRF header or a second factor. (CSRF only applies to the browser, which authenticates with a session cookie.) See authentication for how humans sign in.
Permissions
Authorization is enforced per route. If the token’s user lacks the required
permission, the call fails 403 (see Errors). The catalogue:
| Permission | Grants |
|---|---|
cartography:read / cartography:write | Read / edit the network map (hosts, networks, groups, services) |
rules:read / rules:write | Read / edit flow-matrix rules |
alerting:read / alerting:write | Read / edit detection & anomaly rules and rule sets |
sessions:read | Read sessions, run read-only NFQL helpers |
nfql:execute | Execute NFQL queries and manage saved queries |
alerts:read / alerts:ack | Read / acknowledge & delete detection alerts |
outputs:manage | Manage alert outputs (webhooks, syslog, …) |
sources:manage | Manage enrichment sources and flow exporters |
devices:manage | Manage OPNsense devices |
config:export / config:import | Export / import the YAML config bundle |
license:read / license:manage | Inspect / validate, install, renew and remove the offline license |
users:manage | Manage users, groups and tokens |
auth:manage | Manage LDAP/OIDC and login-security settings |
auditlog:read | Read and verify the audit log |
monitoring:read | Read the GET /api/status health snapshot |
indicators:read | Pull the observed-indicator feed — and nothing else |
system:manage | Storage / retention / backup / master-key operations |
Built-in groups bundle these: admin (everything), analyst
(read + query + rule authoring), auditor (read-only, incl. auditlog:read),
and monitoring (only monitoring:read). Create a custom group to grant an
exact subset.
Errors
Machine paths (/api/…, /ws/…) always answer failures with a JSON body:
| Status | Body | Meaning |
|---|---|---|
401 Unauthorized | {"error":"unauthenticated"} | Missing, unknown, revoked or expired token |
403 Forbidden | {"error":"forbidden"} | Token’s user lacks the required permission |
423 Locked | {"error":"…","holder":"<user>"} | A cartography mutation while another admin holds the edit lock |
400 Bad Request | {"error":"…"} | Malformed request (missing field, bad JSON) |
NFQL compile errors are not HTTP errors.
POST /api/queryreturns200 OKwith anerrorfield populated (see below) so a positioned compile error and a result share one response shape.
Health & status
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET | /healthz | public | Plain-text liveness probe — returns ok |
GET | /api/status | monitoring:read | Full machine-facing daemon snapshot (JSON) |
GET | /api/protocols | public | IANA protocol number → name table (JSON) |
GET /api/status returns byte-for-byte the same payload as obserae-cli status
— one shared snapshot for both surfaces. It is the authenticated counterpart to
the public /healthz probe.
curl -H "Authorization: Bearer obs_…" http://127.0.0.1:8080/api/status
{
"version": "0.27.0",
"commit": "7a8e5e4",
"started_at": "2026-07-07T08:00:00Z",
"uptime_seconds": 3600,
"timestamp": "2026-07-07T09:00:00Z",
"data_version": 10,
"data_version_target": 10,
"flows": { "total": 1284003, "per_sec": 412.5 },
"cartography": { "networks": 12, "hosts": 340, "services": 28, "groups": 9 },
"sessions": { "active": 1204, "half_open": 33, "closed": 98120,
"open_live": 1237, "open_max": 200000, "fill_pct": 0.6,
"pressure": "ok", "evicted": 0,
"dropped_closes": 0, "dropped_emits": 0 },
"rules": { "total": 42, "expansions": 1180, "alert_rules_active": 7 },
"coverage": { "rate_1h": 0.98, "violations_closed_1h": 3 },
"alerts": { "total": 15, "by_status": { "open": 4, "acknowledged": 11 },
"by_severity": { "high": 2, "medium": 9, "low": 4 },
"last_1h": 1, "last_24h": 6 },
"ingestion": { "files_written": 8123, "records_ingested": 1284003,
"last_flush_at": "2026-07-07T08:59:57Z",
"avg_flush_ms": 41.2, "max_flush_ms": 190 },
"storage": { "db_path": "data/obserae.duckdb", "db_size_bytes": 12288,
"disk_free_bytes": 51230000000, "disk_total_bytes": 100000000000 },
"runtime": { "heap_inuse_bytes": 84934656, "goroutines": 61,
"udp_drops": 0, "matcher_cursor_lag_seconds": 1 },
"audit": { "integrity": "verified", "breaks": 0 },
"live_metrics_available": true
}
When the web GUI is disabled (
web.enabled: false) the producer-derived blocks (per_sec,coverage,runtime, ingestion timing, audit verdict) stay at zero andlive_metrics_availableisfalse; the always-available counts are still filled.
NFQL query execution
Run NFQL queries — the same engine the Investigation page uses. Compiled once, executed on the read-only pool with a row cap and timeout.
| Method | Path | Permission | Purpose |
|---|---|---|---|
POST | /api/query | nfql:execute | Execute a query, return columns + rows |
GET | /api/query/columns | sessions:read | Compile only — return output columns & types (?query=…) |
GET | /api/query/schema | sessions:read | Tables, columns, keywords and constants with descriptions |
GET | /api/query/cookbook | sessions:read | Built-in example queries |
GET | /api/query/carto-refs | sessions:read | Cartography names usable in predicates |
GET | /api/query/resolve-ip | sessions:read | Resolve an IP to its host/interface/network (?ip=…) |
Executing a query
POST /api/query with a JSON body:
| Field | Type | Notes |
|---|---|---|
source | string | The NFQL pipeline (required) |
args | array | Positional ? arguments; integers are bound as integers |
as_of | string | Optional RFC3339 instant; pins the “now” of every relative window (the investigate at fire time path). Empty = live now |
curl -X POST http://127.0.0.1:8080/api/query \
-H "Authorization: Bearer obs_…" \
-H "Content-Type: application/json" \
-d '{
"source": "FROM sessions | LAST 3600 | STATS COUNT(*) AS n BY server_port | SORT n DESC | HEAD 5",
"args": []
}'
Response (a result or a compile error, always 200 OK):
{
"columns": [
{ "name": "server_port", "type": "INTEGER" },
{ "name": "n", "type": "BIGINT" }
],
"rows": [
[443, 8123],
[53, 4102],
[22, 512]
],
"compile_ms": 3,
"exec_ms": 41,
"row_count": 3,
"truncated": false
}
On a bad query the same shape carries the message instead:
{ "columns": null, "rows": null, "error": "sem: 1:18: unknown column \"prtocol\"" }
truncated: true means the row cap was hit — narrow the query with HEAD or a
tighter window. Saved queries are managed under /api/queries (list/search with
sessions:read; create/update/delete with nfql:execute).
The SOAR profile
obserae detects; acting on what it finds belongs to an orchestration platform (SOAR, SIRP, automation engine). That platform needs two things from this API, and neither of them is “all 220 operations”: a short list of actions a playbook actually calls, and a typed answer for each so its output fields can be mapped without writing code.
Fourteen operations carry the SOAR tag and the x-soar: true extension:
| Operation | Endpoint | Permission | What a playbook uses it for |
|---|---|---|---|
listAlerts | GET /api/alerts | alerts:read | Poll or reconcile open alerts |
lookupEnrichment | GET /api/enrichment/lookup | sessions:read | Enrich up to 50 addresses in one call |
getHostContext | GET /api/context/host | sessions:read | What one host has been doing, with the policy verdict per peer |
getPeers | POST /api/context/peers | sessions:read | The same question, filtered by direction, scope and instant |
exportIndicators | GET /api/indicators | indicators:read | Pull the observed-address feed — the only enforcement path |
getAlert | GET /api/alerts/{id} | alerts:read | Resume on an alert id, with its evidence |
setAlertStatus | POST /api/alerts/{id}/status | alerts:ack | Take ownership once a ticket exists |
resolveIp | GET /api/query/resolve-ip | sessions:read | Turn an address into a catalogued asset |
getHost | GET /api/carto/hosts/{name} | cartography:read | Read that asset’s interfaces, services, groups |
searchCartography | GET /api/carto/search | cartography:read | Find an entity by name |
findCoveringRule | POST /api/rules/find-covering | rules:read | Ask whether a flow was already allowed |
checkPolicy | POST /api/policy/check | rules:read | The same question, answered in the shape a ticket needs |
getStatus | GET /api/status | monitoring:read | Confirm which instance answered, and that it is alive |
executeQuery | POST /api/query | nfql:execute | The escape hatch, for everything else |
Every one of them has a typed response schema and a response example, which is what an orchestrator shows a user while they build a field mapping.
Two specifications
openapi.yaml— the complete reference, every operation.openapi-soar.yaml— only the fourteen above, with every schema they reference. This is the file to import into a SOAR.
The second is derived from the first automatically and committed to the repository; it is never edited by hand. Importing the full specification works too, but it produces a connector with 220 actions, which nobody can pick from.
Building the playbook that calls them is a page of its own: Response Integrations covers the outbound alert contract, how to verify its signature, and three end-to-end playbooks written against these operations.
A token scoped to the profile
Grant only what a playbook calls. sessions:read, alerts:read,
alerts:ack, cartography:read and rules:read cover eleven of the fourteen —
add nfql:execute only if your playbooks use executeQuery, and
monitoring:read only if they check instance health. None of those permits any
change to the cartography, the rules or the configuration.
Note what sessions:read buys and what it does not: it opens
lookupEnrichment, which reads what the enrichment feeds say, and it does
not open the rest of /api/enrichment, which configures those feeds —
that stays on sources:manage. A playbook token can ask questions about an
address; it cannot switch a feed off.
exportIndicators is the exception to “grant it to the playbook”. Its
indicators:read belongs on the enforcement device’s own credential — the
firewall, the RPZ, the blocklist that pulls the feed — so that revoking that
device’s access does not revoke the playbook’s. It is in no built-in group but
admin; create a group holding just it.
A worked call
curl -sG https://obserae.corp.example/api/enrichment/lookup \
-H "Authorization: Bearer obs_…" \
--data-urlencode "ip=203.0.113.45" \
--data-urlencode "ip=10.0.0.50"
{
"items": [
{ "ip": "203.0.113.45", "scope": "external",
"asn": 15169, "as_org": "Google LLC", "country": "US",
"threat_intel": [
{ "source": "tor", "verdict": "match", "list": "exit-nodes",
"updated_at": "2026-06-04T06:00:00Z" }
],
"carto": { "resolved": false } },
{ "ip": "10.0.0.50", "scope": "internal",
"carto": { "resolved": true, "host": "ci-runner-prod",
"network": "build", "group": "build", "groups": ["build"] } }
]
}
items is in request order, so a playbook that sent a batch reads the answer
positionally. Three answers must not be confused: threat_intel: [] means
checked, no feed lists it; an absent threat_intel means not checked (an
internal address never is); and a single entry with "verdict": "unavailable"
means the enrichment database could not be consulted. Only the first one is
“clean”.
Above 50 addresses the call returns 400. It is never truncated: a playbook
silently handed 50 of its 80 addresses would decide on partial evidence and
have no way to know.
What obserae will not do
obserae is out-of-band: it reads flow records and is on the path of nothing. It does not block, quarantine or reconfigure anything, and there is no API here that does — enforcement belongs to the platform that owns the firewall or the EDR. What this API offers is the evidence and the context that platform needs to decide.
Indicators — obserae publishes a list, it does not block
GET /api/indicators is the one place this API touches enforcement, and it
touches it as weakly as possible: it publishes a list, and something else
decides what to do with it. Nothing here calls a device, and there is no API
that does.
curl -sG https://obserae.corp.example/api/indicators \
-H "Authorization: Bearer obs_…" \
--data-urlencode "since=24h" \
--data-urlencode "min_severity=high" \
--data-urlencode "format=plain"
# obserae observed-indicator feed
# instance: obserae-paris-dc1
# generated: 2026-06-04T12:00:00Z
# window: 24h
# count: 2
# obserae publishes this list and does not block: it is out-of-band
# and on the path of nothing. What happens to these addresses is
# decided by whatever consumes this feed.
198.51.100.7
203.0.113.9
The list holds the publicly routable addresses obserae’s own detection rules fired on. Private addresses are never published: an RFC1918 line in a list a firewall acts on would have someone block their own network. An address is one indicator however many alerts saw it, and it carries the highest severity any of them gave it.
| Parameter | Values | Default |
|---|---|---|
type | ip | ip |
since | 1h, 24h, 7d, 30d | 24h |
min_severity | info … critical | none |
format | plain, json, csv, misp | plain |
limit | 1–5000 | 5000 |
plain is what a firewall or an RPZ ingests directly; json and csv carry the
provenance (which rules saw the address, and when); misp is a MISP feed
manifest.
Poll it conditionally
Every response carries ETag and Last-Modified, and an unchanged
If-None-Match is answered 304 Not Modified with no body:
curl -sD- -o/dev/null -G https://obserae.corp.example/api/indicators \
-H "Authorization: Bearer obs_…" -H 'If-None-Match: "a3f1…"' \
--data-urlencode "since=24h"
A consumer polling every five minutes without that is a full recomputation
twelve times an hour, forever. The ETag covers the format too, so switching
representation always returns the new body rather than a 304 you would honour
indefinitely.
Its own credential
indicators:read gates this endpoint and nothing else, and belongs to no
built-in group but admin. Create a group holding just it and mint the token for
the device that pulls the feed — see
authentication › tokens for machines. A token holding only
indicators:read is refused every other route in this API.
Endpoint reference by domain
All paths below are under the base URL and require the listed permission. GET
returns JSON; mutations take a JSON body and return the updated resource or a
status envelope. {…} marks a path parameter.
Cartography — the network map
cartography:read for GET, cartography:write for mutations. Mutations
also require the edit lock; a non-holder gets 423 Locked.
| Method | Path | Purpose |
|---|---|---|
GET | /api/carto/graph | Full graph (nodes, edges, groups) |
GET | /api/carto/layout | /color | /icon | /os-icon | Layout & styling data |
GET | /api/carto/search | Search hosts/networks/groups — returns {query, matches: […]} |
GET | /api/carto/hosts/{name} | Read one asset: interfaces, services, and the groups it belongs to |
POST | /api/carto/networks | /groups | /hosts | /interfaces | /services | Create |
PATCH DELETE | /api/carto/{kind}/{name} | Update / delete |
POST | /api/carto/hosts/{name}/clone | Clone a host |
POST | /api/carto/auto-layout | Recompute layout |
GET POST | /api/carto/lock, /lock/heartbeat, /lock/release | Acquire/renew/release the single-editor lock |
The lock lifecycle body is { "token": "<opaque per-tab id>" }. A page-leave
client may release with form fields token and csrf_token. Tokens are capped
at 256 bytes and a live foreign session receives 423 Locked.
Dashboards
dashboards:read lists and reads dashboards; dashboards:write creates them,
runs panel analysis/previews and manages protected edits. PATCH and DELETE
of an existing dashboard require that dashboard’s edit lease, otherwise they
return 423 Locked. Leases are per dashboard and independent from the global
cartography lease.
| Method | Path | Purpose |
|---|---|---|
GET POST | /api/dashboards | List or create dashboards |
GET PATCH DELETE | /api/dashboards/{id} | Read, replace at an expected revision, or delete a dashboard |
GET POST | /api/dashboards/{id}/lock | Read or acquire this dashboard’s edit lease |
POST | /api/dashboards/{id}/lock/heartbeat | Renew this dashboard’s edit lease |
POST | /api/dashboards/{id}/lock/release | Release this dashboard’s edit lease; form beacons may include csrf_token |
POST | /api/dashboards/analyze | Compile a panel source and return its typed output shape without reading rows |
POST | /api/dashboards/preview | Run one bounded panel preview |
POST | /api/dashboards/{id}/run | Run all panels in an authenticated dashboard |
Network vocabulary
cartography:read lists the combined local and rule-set catalogue;
cartography:write creates or deletes local values. Names beginning with
std. are reserved. Rule-set-owned values identify their source and cannot be
deleted through these endpoints.
| Method | Path | Purpose |
|---|---|---|
GET | /api/vocabulary | List zones, environments, roles and service purposes with their source |
POST | /api/vocabulary | Create a local value (purposes include port/protocol pairs) |
POST | /api/vocabulary/{type}/{name}/delete/preview | List cartography assignments that deletion will clear |
DELETE | /api/vocabulary/{type}/{name} | Delete a local value and clear those assignments |
Flow-matrix rules
rules:read / rules:write. See rules.
| Method | Path | Purpose |
|---|---|---|
GET | /api/rules, /api/rules/{name} | List / read |
POST | /api/rules | Create |
PATCH DELETE | /api/rules/{name} | Update / delete |
POST | /api/rules/{name}/enable | /disable | Toggle |
POST | /api/rules/bulk | Bulk apply |
POST | /api/rules/preview | Dry-run a rule’s expansion |
POST | /api/rules/find-covering | Ask whether the matrix already allows a flow (rules:read) |
find-covering is the exception to the read/write split above: it answers a
question and changes nothing, so it needs only rules:read. It is a POST
because a src/dst/port/protocol tuple does not fit in a path — an automation
checking a flow before opening a ticket should not need permission to edit the
matrix. Send at least one of src or dst, each as host:NAME, group:NAME,
network:NAME or a bare IP; an empty matches array means nothing covers the
flow, which is an answer, not an error.
Detection (alerting) rules & rule sets
alerting:read / alerting:write. See alerting and
rulesets.
| Method | Path | Purpose |
|---|---|---|
GET POST | /api/alerting-rules | List / create alert rules |
GET PATCH DELETE | /api/alerting-rules/{id} | Read / update / delete |
POST | /api/alerting-rules/{id}/toggle | /duplicate | Enable-disable / clone |
GET | /api/alerting-rules/{id}/runs | Recent evaluations |
GET | /api/rulesets, /api/rulesets/shipped, /api/rulesets/{id} | Browse rule packs |
POST | /api/rulesets/validate | /import | Validate / import a pack |
POST DELETE | /api/rulesets/{id}/toggle, /api/rulesets/{id} | Toggle / delete |
Anomaly detection
alerting:read / alerting:write. See anomaly detection.
| Method | Path | Purpose |
|---|---|---|
GET | /api/anomaly/overview, /api/anomaly/rules | Overview & rule list |
GET | /api/anomaly/rules/{id}/baselines, .../baselines/{keyhash} | Learned baselines |
GET | /api/anomaly/rules/{id}/baselines/{keyhash}/series | /heatmap | Time series |
GET | /api/anomaly/rules/{id}/fires | Recent fires |
POST | /api/anomaly/rules/{id}/reset-baseline | Reset the baseline |
Anomaly Lab
alerting:read, read-only: the preview replays a signal through estimator settings you
choose and compares them. Applying a tuned setting is an ordinary
PATCH /api/alerting-rules/{id} (alerting:write).
A signal is either rule_id, or query_id / query_text together with metric
and group_by — so a rule can be worked out before it exists.
| Method | Path | Purpose |
|---|---|---|
GET | /api/anomaly-lab/signals | Anomaly rules (with their live settings) + saved queries |
GET | /api/anomaly-lab/entities | Entities, scan scope, observation count and truncation over the requested history |
GET | /api/anomaly-lab/fold | One entity’s series, exact z-scores, model/policy decisions, persistence progress, simulated and recorded notifications |
GET | /api/anomaly-lab/validate | Whole-signal coverage and simulated alert load across every replayed entity |
GET | /api/anomaly-lab/sweep | A bounded per-entity load comparison; not a fleet-wide accuracy recommendation |
bucket_secs defaults to the rule’s cadence. The response still identifies the
result as a bucketed historical approximation: a rolling live query is not always
equivalent to independent historical buckets, and live cooldown is not applied.
Each point exposes unusual, z_score, policy_decision, policy_reason,
streak, required, severity_bypass and fires; clients should render these
server decisions instead of recomputing anomaly or notification state.
Detection alerts
alerts:read for GET, alerts:ack for mutations.
| Method | Path | Purpose |
|---|---|---|
GET | /api/alerts | List alerts (filters via query params) |
GET | /api/alerts/{id} | Read one alert, with the rows its rule matched |
POST | /api/alerts/{id}/status | Acknowledge / change status |
POST | /api/alerts/delete | Bulk delete |
DELETE | /api/alerts/{id} | Delete one |
GET /api/alerts takes severity, status, rule_id, rule_name (also
accepted as rule), tag, since, until, limit and offset. since and
until accept an RFC3339 instant or a relative age (30m, 24h, 7d).
Prefer rule_id over rule_name for anything durable: a rule can be renamed,
and a correlation built on its name breaks the day it is.
The response is an envelope, not a bare array:
{ "items": [ … ], "total": 41, "limit": 100, "offset": 0 }
total counts every alert the filter matched, independent of the window
returned, so a client that pages knows when to stop. limit defaults to 100
and is capped at 10000; an over-large request is clamped rather than
rejected, and limit echoes what was actually applied.
GET /api/alerts/{id} adds the evidence: columns names the sampled fields,
rows holds them positionally, and records is the same rows projected onto
those names. Read records. Addressing a field by name is what keeps an
integration working when someone edits the rule’s KEEP.
The status vocabulary is new, ack and closed — anything else is a 400.
A status change and a delete both answer 204 No Content: there is no body
to decode.
# List, then acknowledge one alert
curl -H "Authorization: Bearer obs_…" http://127.0.0.1:8080/api/alerts
curl -X POST http://127.0.0.1:8080/api/alerts/42/status \
-H "Authorization: Bearer obs_…" -H "Content-Type: application/json" \
-d '{"status":"ack"}'
Sessions
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET | /api/sessions/riverview | sessions:read | River-view session aggregation (JSON) |
GET | /api/context/host | sessions:read | One host’s peers, services, volume and new peers |
POST | /api/context/peers | sessions:read | With whom did this address talk — filtered |
Host context without a query
GET /api/context/host?ip=10.0.0.50&window=24h answers the questions an
automation asks about a host — who it talked to, who talked to it, what it
served, how much it moved, and which peers are new — without writing NFQL.
curl -sG https://obserae.corp.example/api/context/host \
-H "Authorization: Bearer obs_…" \
--data-urlencode "ip=10.0.0.50" \
--data-urlencode "window=24h"
Every peer carries policy_covered, the flow matrix’s verdict on that exact
conversation — the same one policy/check gives. That is what makes
the answer a segmentation report rather than a traffic dump: the peers a host
talks to that no rule ever allowed are one filter away.
window is 1h, 24h (the default) or 7d, and nothing else: it is what
bounds the scan, so an unsupported value is refused rather than rounded to one
you did not choose. as_of moves that window without widening it — pass an
alert’s fired_at to see what the host was doing when it fired.
new_peers_in_window means “did not talk to it in the equally long period
before the window”. That is a deliberate definition: a literal never before
would read the whole retained history to answer a question about one hour.
An address obserae never saw returns 200 with empty lists, not an error. Every
list is present and empty rather than null.
Just the peers, filtered
POST /api/context/peers is the same conversation question, parameterised:
curl -sX POST https://obserae.corp.example/api/context/peers \
-H "Authorization: Bearer obs_…" -H "Content-Type: application/json" \
-d '{"ip":"10.0.0.50","window":"24h","direction":"out","scope":"external","limit":50}'
direction is out, in or both; scope is external, internal or
any; limit defaults to 100 and is clamped at 1000, with truncated saying
the list was cut. Every one of those vocabularies is closed — an unrecognised
value is refused rather than replaced by the widest answer, which you would read
as a result for the filter you asked for.
Pass an alert’s fired_at as as_of and you see the conversations that alert
was about, rather than the ones happening now.
match=matched|unmatched narrows on the rule-match state. unmatched means
evaluated and matched by no rule: conversations closed after
evaluation_horizon — the matcher has not reached them — are excluded and
reported in pending_conversations instead. Each pair carries its own
matched_conversations / unmatched_conversations / pending_conversations
split, so a pair legitimately returned by both filters is readable as such.
Enrichment & exporters
sources:manage, except the lookup, which is a read and takes
sessions:read. See enrichment and exporters.
| Method | Path | Purpose |
|---|---|---|
GET | /api/enrichment/lookup?ip=… | Look up to 50 addresses up — sessions:read |
GET PATCH | /api/enrichment | Read / update enrichment settings |
GET POST | /api/enrichment/sources | List sources / create a custom URL or uploaded source |
PATCH DELETE | /api/enrichment/sources/{name} | Toggle a source / delete a custom source |
PUT | /api/enrichment/sources/{name}/content | Replace a custom uploaded source’s file |
POST | /api/enrichment/sources/{name}/refresh | Refresh now |
DELETE | /api/enrichment/sources/{name}/ranges | Purge downloaded ranges but keep the source |
GET | /api/exporters | List flow exporters |
PATCH DELETE | /api/exporters/{ip} | Update / forget an exporter |
POST | /api/exporters/rescan | Rescan for exporters |
Create a URL source with JSON:
curl -X POST http://127.0.0.1:8080/api/enrichment/sources \
-H "Authorization: Bearer obs_…" -H 'Content-Type: application/json' \
-d '{"name":"corp_c2","display_name":"Corporate C2","transport":"url","url":"https://intel.example/feed.csv","format":"csv","refresh_interval":"24h","parser":{"indicator_field":"ip"}}'
For an upload, send multipart fields definition (the same JSON with
transport=upload) and file. The response includes the detected format,
accepted/invalid/duplicate counts and SHA-256 digest. Feed bodies are limited to
64 MiB; validation errors return 422 and leave the prior snapshot untouched.
Devices (OPNsense)
devices:manage. See sources.
| Method | Path | Purpose |
|---|---|---|
GET POST | /api/devices | List / add a device |
GET PATCH DELETE | /api/devices/{id} | Read / update / remove |
POST | /api/devices/{id}/refresh | Poll the device now |
Outputs
outputs:manage. See outputs.
| Method | Path | Purpose |
|---|---|---|
GET POST | /api/outputs | List / create an output |
GET PATCH DELETE | /api/outputs/{id} | Read / update / delete |
POST | /api/outputs/{id}/toggle | /test | Enable-disable / send a test event |
GET | /api/outputs/{id}/deliveries | Recent delivery attempts |
Lifecycle — storage, retention, backup
system:manage. See lifecycle.
| Method | Path | Purpose |
|---|---|---|
GET | /api/lifecycle/storage | Storage usage |
GET PATCH | /api/lifecycle/retention | Read / set retention policy |
POST | /api/lifecycle/retention/run | Run retention now |
GET PATCH | /api/lifecycle/backup | Read / set backup schedule |
POST | /api/lifecycle/backup/run, /plan, /restore | Run / plan / restore |
GET | /api/lifecycle/backup/timeline, /next-action | Schedule state |
Configuration I/O
See CLI › config for the equivalent bundle operations.
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET | /api/config/export | config:export | Export the full YAML config bundle (the Backup page’s toolbar action) |
POST | /api/config/import | config:import | Import a bundle (synchronous — for the CLI and scripting). The whole file is validated before any write |
POST | /api/config/restore | config:import | Restore a bundle asynchronously — 202 + restore_id, 409 if one is already running (the Restore page’s flow) |
GET | /api/config/restore/active | config:import | Live status of the running restore (for banner re-attach) |
GET | /api/config/restore/{id}/stream | config:import | Stream restore progress as Server-Sent Events |
A bundle containing the top-level license section also requires
license:manage. The section contains one artifact string, which is
re-verified before any section is applied. Renewal replaces that value and
removal causes subsequent exports to omit the section.
Offline license
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET | /api/license | license:read | Read the presentation-safe active license and its current expiry phase |
POST | /api/license/validate | license:manage | Verify a multipart license file without installing it |
PUT | /api/license | license:manage | Install or renew a verified multipart license file |
DELETE | /api/license | license:manage | Remove it; JSON body {"company_name":"Exact signed name"} |
The three signed plans are Business (fewer than 100 employees),
Business+ (101–500), and Enterprise (more than 500). All three licenses
include the same professional feature set. The API reports notice below 60
days, warning below 30, critical below 7, then grace for 30 days after expiry.
Professional access remains enabled through grace and is disabled in
none, upcoming and expired.
Professional-only endpoints return 403 with
{"error":"professional license required"} in Community. This covers Audit
log, Reports, LDAP Authentication, OIDC / SSO and Groups. Only the built-in
admin identity remains usable. std.enterprise and std.anomaly rule-set
mutations, and commercial output mutations, return the same error. Community
output types are exactly webhook, discord, telegram, smtp and gotify.
List responses keep stored commercial rule sets and outputs visible with
license_locked: true; their background evaluation/delivery is paused. No
stored customer configuration is deleted, and renewal clears the lock.
The same policy is enforced server-side on the Unix control API used by
obserae-cli; hiding a command in a client is never the security boundary.
Missing license-policy wiring fails closed as Community.
Master key
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET | /api/masterkey/export | system:manage | Read the key as base64 ({"key": "…"}) |
GET | /api/masterkey/download | system:manage | Download masterkey.bin — the raw 32 bytes the daemon reads at boot |
POST | /api/masterkey/import | system:manage | Rotate to a new key: every secret re-encrypted and every audit seal re-signed, live |
Audit log
auditlog:read. See verify for the tamper-evidence scheme.
| Method | Path | Purpose |
|---|---|---|
GET | /api/auditlog | Query the journal (filters via query params) |
GET | /api/auditlog/histogram, /facets | Aggregates for the UI |
GET | /api/auditlog/verify | Verify the hash chain & seals |
GET | /api/auditlog/export | Export matching entries |
Identity & access administration
users:manage unless noted; LDAP/OIDC and login security need auth:manage.
The account self-service endpoints require only an authenticated token (any
user managing their own MFA/password).
| Method | Path | Permission | Purpose |
|---|---|---|---|
POST | /api/users | users:manage | Create a user |
PATCH DELETE | /api/users/{username} | users:manage | Update / delete |
POST | /api/users/{username}/password | /mfa/reset | users:manage | Reset password / MFA |
POST | /api/groups, PATCH/DELETE /api/groups/{name} | users:manage | Manage groups |
POST | /api/tokens, /api/tokens/{id}/revoke, DELETE /api/tokens/{id} | users:manage | Manage API tokens |
GET PUT | /api/ldap/config, POST /api/ldap/test-connection | auth:manage | LDAP config |
GET PUT | /api/oidc/config, POST /api/oidc/test-connection | auth:manage | OIDC config |
GET PUT | /api/auth/login-security, PUT /api/auth/mfa-policy | users:manage | Login-security & MFA policy |
POST | /api/account/mfa/begin | /activate | /disable, /api/account/password | any authenticated | Self-service MFA & password |
WebSockets
Real-time streams for the GUI, also usable by a client that keeps a socket open.
Connect with the same Bearer token; an unauthenticated handshake gets a 401.
| Path | Permission | Pushes |
|---|---|---|
/ws/health | any authenticated | Health / cockpit snapshots |
/ws/alerts | alerts:read | alert.fired notifications |
/ws/carto | cartography:read | carto.changed broadcasts when the map is edited |
Detection-backlog fields on /ws/health
Worth knowing if you monitor obserae from outside the GUI, because a detection outage does not look like an ingestion outage:
| Field | Meaning |
|---|---|
matcher_cursor_lag_seconds | How far behind the slowest rule cursor is. This is the signal to alert on — it is exact and rate-independent. |
matcher_cursor_lag | Estimate of the unmatched session count (age × observed close rate). For scale, not for alerting. |
matcher_behind | The cursor age is past the warning threshold (OBSERAE_MATCHER_CURSOR_AGE_WARN, default 5m). Recoverable: the matcher is catching up one matcher.catchup_window per tick. |
matcher_shedding | The matcher fell past matcher.max_lag and gave up on history it could not catch up on. Those sessions are never matched against any rule. Stays true for 24h; the skipped window is in the audit journal as matcher.backlog.shed. |
Ingestion and detection are decoupled — sessions are written to parquet in RAM, and the matcher re-reads them through DuckDB — so the writer pool can look perfectly healthy while detection has stopped. Alert on the cursor age, not on ingestion counters.
See also
- Web GUI — where to mint tokens and manage permissions.
- Authentication — how humans sign in (local, LDAP, OIDC).
- NFQL — the query language used by
POST /api/query. - CLI — the admin socket, which mirrors many of these operations.
Browse the interactive API reference. Every endpoint, schema and example rendered from the OpenAPI spec: /docs/api-reference/.