Daily Use

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:8080 by default (web.bind in configuration). All examples below use http://127.0.0.1:8080; substitute your host and port.
  • Content type — send Content-Type: application/json on requests with a body; responses are always application/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? Use openapi-soar.yaml instead — 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 GUIIdentity & 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:

PermissionGrants
cartography:read / cartography:writeRead / edit the network map (hosts, networks, groups, services)
rules:read / rules:writeRead / edit flow-matrix rules
alerting:read / alerting:writeRead / edit detection & anomaly rules and rule sets
sessions:readRead sessions, run read-only NFQL helpers
nfql:executeExecute NFQL queries and manage saved queries
alerts:read / alerts:ackRead / acknowledge & delete detection alerts
outputs:manageManage alert outputs (webhooks, syslog, …)
sources:manageManage enrichment sources and flow exporters
devices:manageManage OPNsense devices
config:export / config:importExport / import the YAML config bundle
license:read / license:manageInspect / validate, install, renew and remove the offline license
users:manageManage users, groups and tokens
auth:manageManage LDAP/OIDC and login-security settings
auditlog:readRead and verify the audit log
monitoring:readRead the GET /api/status health snapshot
indicators:readPull the observed-indicator feed — and nothing else
system:manageStorage / 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:

StatusBodyMeaning
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/query returns 200 OK with an error field populated (see below) so a positioned compile error and a result share one response shape.


Health & status

MethodPathPermissionPurpose
GET/healthzpublicPlain-text liveness probe — returns ok
GET/api/statusmonitoring:readFull machine-facing daemon snapshot (JSON)
GET/api/protocolspublicIANA 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 and live_metrics_available is false; 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.

MethodPathPermissionPurpose
POST/api/querynfql:executeExecute a query, return columns + rows
GET/api/query/columnssessions:readCompile only — return output columns & types (?query=…)
GET/api/query/schemasessions:readTables, columns, keywords and constants with descriptions
GET/api/query/cookbooksessions:readBuilt-in example queries
GET/api/query/carto-refssessions:readCartography names usable in predicates
GET/api/query/resolve-ipsessions:readResolve an IP to its host/interface/network (?ip=…)

Executing a query

POST /api/query with a JSON body:

FieldTypeNotes
sourcestringThe NFQL pipeline (required)
argsarrayPositional ? arguments; integers are bound as integers
as_ofstringOptional 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:

OperationEndpointPermissionWhat a playbook uses it for
listAlertsGET /api/alertsalerts:readPoll or reconcile open alerts
lookupEnrichmentGET /api/enrichment/lookupsessions:readEnrich up to 50 addresses in one call
getHostContextGET /api/context/hostsessions:readWhat one host has been doing, with the policy verdict per peer
getPeersPOST /api/context/peerssessions:readThe same question, filtered by direction, scope and instant
exportIndicatorsGET /api/indicatorsindicators:readPull the observed-address feed — the only enforcement path
getAlertGET /api/alerts/{id}alerts:readResume on an alert id, with its evidence
setAlertStatusPOST /api/alerts/{id}/statusalerts:ackTake ownership once a ticket exists
resolveIpGET /api/query/resolve-ipsessions:readTurn an address into a catalogued asset
getHostGET /api/carto/hosts/{name}cartography:readRead that asset’s interfaces, services, groups
searchCartographyGET /api/carto/searchcartography:readFind an entity by name
findCoveringRulePOST /api/rules/find-coveringrules:readAsk whether a flow was already allowed
checkPolicyPOST /api/policy/checkrules:readThe same question, answered in the shape a ticket needs
getStatusGET /api/statusmonitoring:readConfirm which instance answered, and that it is alive
executeQueryPOST /api/querynfql:executeThe 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.

ParameterValuesDefault
typeipip
since1h, 24h, 7d, 30d24h
min_severityinfocriticalnone
formatplain, json, csv, mispplain
limit1–50005000

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.

MethodPathPurpose
GET/api/carto/graphFull graph (nodes, edges, groups)
GET/api/carto/layout | /color | /icon | /os-iconLayout & styling data
GET/api/carto/searchSearch 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 | /servicesCreate
PATCH DELETE/api/carto/{kind}/{name}Update / delete
POST/api/carto/hosts/{name}/cloneClone a host
POST/api/carto/auto-layoutRecompute layout
GET POST/api/carto/lock, /lock/heartbeat, /lock/releaseAcquire/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.

MethodPathPurpose
GET POST/api/dashboardsList or create dashboards
GET PATCH DELETE/api/dashboards/{id}Read, replace at an expected revision, or delete a dashboard
GET POST/api/dashboards/{id}/lockRead or acquire this dashboard’s edit lease
POST/api/dashboards/{id}/lock/heartbeatRenew this dashboard’s edit lease
POST/api/dashboards/{id}/lock/releaseRelease this dashboard’s edit lease; form beacons may include csrf_token
POST/api/dashboards/analyzeCompile a panel source and return its typed output shape without reading rows
POST/api/dashboards/previewRun one bounded panel preview
POST/api/dashboards/{id}/runRun 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.

MethodPathPurpose
GET/api/vocabularyList zones, environments, roles and service purposes with their source
POST/api/vocabularyCreate a local value (purposes include port/protocol pairs)
POST/api/vocabulary/{type}/{name}/delete/previewList 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.

MethodPathPurpose
GET/api/rules, /api/rules/{name}List / read
POST/api/rulesCreate
PATCH DELETE/api/rules/{name}Update / delete
POST/api/rules/{name}/enable | /disableToggle
POST/api/rules/bulkBulk apply
POST/api/rules/previewDry-run a rule’s expansion
POST/api/rules/find-coveringAsk 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.

MethodPathPurpose
GET POST/api/alerting-rulesList / create alert rules
GET PATCH DELETE/api/alerting-rules/{id}Read / update / delete
POST/api/alerting-rules/{id}/toggle | /duplicateEnable-disable / clone
GET/api/alerting-rules/{id}/runsRecent evaluations
GET/api/rulesets, /api/rulesets/shipped, /api/rulesets/{id}Browse rule packs
POST/api/rulesets/validate | /importValidate / import a pack
POST DELETE/api/rulesets/{id}/toggle, /api/rulesets/{id}Toggle / delete

Anomaly detection

alerting:read / alerting:write. See anomaly detection.

MethodPathPurpose
GET/api/anomaly/overview, /api/anomaly/rulesOverview & rule list
GET/api/anomaly/rules/{id}/baselines, .../baselines/{keyhash}Learned baselines
GET/api/anomaly/rules/{id}/baselines/{keyhash}/series | /heatmapTime series
GET/api/anomaly/rules/{id}/firesRecent fires
POST/api/anomaly/rules/{id}/reset-baselineReset 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.

MethodPathPurpose
GET/api/anomaly-lab/signalsAnomaly rules (with their live settings) + saved queries
GET/api/anomaly-lab/entitiesEntities, scan scope, observation count and truncation over the requested history
GET/api/anomaly-lab/foldOne entity’s series, exact z-scores, model/policy decisions, persistence progress, simulated and recorded notifications
GET/api/anomaly-lab/validateWhole-signal coverage and simulated alert load across every replayed entity
GET/api/anomaly-lab/sweepA 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.

MethodPathPurpose
GET/api/alertsList alerts (filters via query params)
GET/api/alerts/{id}Read one alert, with the rows its rule matched
POST/api/alerts/{id}/statusAcknowledge / change status
POST/api/alerts/deleteBulk 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

MethodPathPermissionPurpose
GET/api/sessions/riverviewsessions:readRiver-view session aggregation (JSON)
GET/api/context/hostsessions:readOne host’s peers, services, volume and new peers
POST/api/context/peerssessions:readWith 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.

MethodPathPurpose
GET/api/enrichment/lookup?ip=…Look up to 50 addresses up — sessions:read
GET PATCH/api/enrichmentRead / update enrichment settings
GET POST/api/enrichment/sourcesList 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}/contentReplace a custom uploaded source’s file
POST/api/enrichment/sources/{name}/refreshRefresh now
DELETE/api/enrichment/sources/{name}/rangesPurge downloaded ranges but keep the source
GET/api/exportersList flow exporters
PATCH DELETE/api/exporters/{ip}Update / forget an exporter
POST/api/exporters/rescanRescan 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.

MethodPathPurpose
GET POST/api/devicesList / add a device
GET PATCH DELETE/api/devices/{id}Read / update / remove
POST/api/devices/{id}/refreshPoll the device now

Outputs

outputs:manage. See outputs.

MethodPathPurpose
GET POST/api/outputsList / create an output
GET PATCH DELETE/api/outputs/{id}Read / update / delete
POST/api/outputs/{id}/toggle | /testEnable-disable / send a test event
GET/api/outputs/{id}/deliveriesRecent delivery attempts

Lifecycle — storage, retention, backup

system:manage. See lifecycle.

MethodPathPurpose
GET/api/lifecycle/storageStorage usage
GET PATCH/api/lifecycle/retentionRead / set retention policy
POST/api/lifecycle/retention/runRun retention now
GET PATCH/api/lifecycle/backupRead / set backup schedule
POST/api/lifecycle/backup/run, /plan, /restoreRun / plan / restore
GET/api/lifecycle/backup/timeline, /next-actionSchedule state

Configuration I/O

See CLI › config for the equivalent bundle operations.

MethodPathPermissionPurpose
GET/api/config/exportconfig:exportExport the full YAML config bundle (the Backup page’s toolbar action)
POST/api/config/importconfig:importImport a bundle (synchronous — for the CLI and scripting). The whole file is validated before any write
POST/api/config/restoreconfig:importRestore a bundle asynchronously — 202 + restore_id, 409 if one is already running (the Restore page’s flow)
GET/api/config/restore/activeconfig:importLive status of the running restore (for banner re-attach)
GET/api/config/restore/{id}/streamconfig:importStream 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

MethodPathPermissionPurpose
GET/api/licenselicense:readRead the presentation-safe active license and its current expiry phase
POST/api/license/validatelicense:manageVerify a multipart license file without installing it
PUT/api/licenselicense:manageInstall or renew a verified multipart license file
DELETE/api/licenselicense:manageRemove 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

MethodPathPermissionPurpose
GET/api/masterkey/exportsystem:manageRead the key as base64 ({"key": "…"})
GET/api/masterkey/downloadsystem:manageDownload masterkey.bin — the raw 32 bytes the daemon reads at boot
POST/api/masterkey/importsystem:manageRotate 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.

MethodPathPurpose
GET/api/auditlogQuery the journal (filters via query params)
GET/api/auditlog/histogram, /facetsAggregates for the UI
GET/api/auditlog/verifyVerify the hash chain & seals
GET/api/auditlog/exportExport 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).

MethodPathPermissionPurpose
POST/api/usersusers:manageCreate a user
PATCH DELETE/api/users/{username}users:manageUpdate / delete
POST/api/users/{username}/password | /mfa/resetusers:manageReset password / MFA
POST/api/groups, PATCH/DELETE /api/groups/{name}users:manageManage groups
POST/api/tokens, /api/tokens/{id}/revoke, DELETE /api/tokens/{id}users:manageManage API tokens
GET PUT/api/ldap/config, POST /api/ldap/test-connectionauth:manageLDAP config
GET PUT/api/oidc/config, POST /api/oidc/test-connectionauth:manageOIDC config
GET PUT/api/auth/login-security, PUT /api/auth/mfa-policyusers:manageLogin-security & MFA policy
POST/api/account/mfa/begin | /activate | /disable, /api/account/passwordany authenticatedSelf-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.

PathPermissionPushes
/ws/healthany authenticatedHealth / cockpit snapshots
/ws/alertsalerts:readalert.fired notifications
/ws/cartocartography:readcarto.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:

FieldMeaning
matcher_cursor_lag_secondsHow far behind the slowest rule cursor is. This is the signal to alert on — it is exact and rate-independent.
matcher_cursor_lagEstimate of the unmatched session count (age × observed close rate). For scale, not for alerting.
matcher_behindThe 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_sheddingThe 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/.