Skip to content

REST API

Collections, writes, one host's connections, scopes and problem details

Edit this page
On this page

Everything under /api/v1/ is REST over HTTP with JSON bodies. Zod schemas in packages/contracts are the source of truth: they generate the OpenAPI document, and the document generates the Go client the daemon uses. The three cannot drift.

The document is OpenAPI 3.0.3, not 3.1, and deliberately: oapi-codegen and kin-openapi do not accept 3.1's numeric exclusiveMinimum/Maximum, so raising the version would break the Go client generation this whole chain exists for.

It is served at /openapi.json and describes the daemon protocol and the public inventory API. Any endpoint absent from that document is internal, including everything the UI calls over tRPC.

"The three cannot drift" is checked rather than asserted: a test compares every collection's accepted query parameters against the document's, in both directions, so adding a filter and forgetting the contract fails the build instead of shipping an endpoint no generated client can reach.

The public API

ADR-0003 was accepted on 2026-08-18, so /api/v1/ is no longer a private arrangement between this codebase and its own daemon. An endpoint in the published document may gain fields and may not lose them; breaking that is a v2, not a patch. Anything absent from /openapi.json is internal — including every call the UI makes over tRPC.

Collections

Eight, all reading with inventory:read, all answering in the same envelope — { data, nextCursor, hasMore } — because a client written against one should be able to read another without changing how it pages.

CollectionFilters beyond limit and cursor
GET /api/v1/hostssiteId, search
GET /api/v1/serviceshostId
GET /api/v1/networkssiteId
GET /api/v1/sites—
GET /api/v1/certificateshostId, trustStatus
GET /api/v1/vulnerabilitiesserviceId, state, severity
GET /api/v1/snapshotssiteId, reason
GET /api/v1/topologiessiteId, kind, status

search on hosts takes one of three things and decides which it was given: a uuid matches the host id, anything that parses as an address or a CIDR matches by address — 10.0.0.0/24 finds every host in the range — and everything else matches the hostname or FQDN. Two characters minimum.

A host with no resolved name is findable by address, which is the honest answer to searching by name for something that has none:

http
GET /api/v1/hosts?search=lyo-app01
GET /api/v1/hosts?search=10.20.0.0/24

A snapshot carries its stats — the row count per entity type at the moment of capture — so "how many hosts did we have in March" is one request rather than one per snapshot. A topology carries nodeCount and edgeCount for the same reason; its layout stays internal, because it is React Flow's business and would be a published field we could never change.

bash
curl -H "Authorization: Bearer lsk_..." \
  "https://loomscope.example.com/api/v1/vulnerabilities?state=open&severity=critical"

An unknown query parameter is a 400, not an ignored one. A caller filtering by a name this API does not know would otherwise receive the whole collection and treat it as the filtered one — which, on a vulnerability list, reads as reassurance. The refusal names what the collection does accept.

The same applies to a filter's value: ?state=fixed is a 400, because fixed is not one of the four states and an empty page would read as "you have no findings".

One host and what it talks to

GET /api/v1/hosts/{id}/topology
GET /api/v1/hosts/{id}/topology.svg

The collections answer "what is on this network". This answers "what does this machine talk to", which is the question asked during an incident and which otherwise needs four tables joined by hand.

Four sections, each from a different source and each saying so: ports from the scan, inbound and outbound from observed flows, neighbours from LLDP or CDP. ?windowHours= sets how far back the flows are read (default 24, capped at 30 days) and the response repeats the window it used, because a relationship older than it is simply absent.

Read sources before reading an empty list. "Nothing talks to this host" and "nothing was watching" produce identical arrays, and only the second is a configuration problem:

json
{
  "inbound": [],
  "sources": { "flows": false, "neighbours": false }
}

flows: false means no flow record exists anywhere in the organisation — so there is no collector running, and the empty list says nothing about the host. See flow collection for how to get one.

A peer with hostId: null is an address the inventory does not know. Those are kept rather than dropped for failing to join: traffic from outside the estate is the kind worth seeing.

The .svg variant draws the same answer — callers on the left, the host in the middle, what it reaches on the right — and states in the image what it left out and which window it drew. It is a vector so it stays sharp in a ticket or a slide; there is no raster variant, which would mean a headless browser in the image for one route.

Writes

Four, because they are the four an operator genuinely automates. Everything else an integration might want to change is a policy decision that belongs in the UI, where it can be explained.

OperationScopeSuccess
POST /api/v1/networksinventory:write201
POST /api/v1/scansscan:run202
PATCH /api/v1/vulnerabilities/{id}inventory:write200
POST /api/v1/snapshotsinventory:write201

scan:run is deliberately not inventory:write. Writing a record changes what Loomscope believes; running a scan sends packets across the customer's network. A key for a reporting integration should hold the first and not the second.

bash
curl -X POST "https://loomscope.example.com/api/v1/networks" \
  -H "Authorization: Bearer lsk_..." \
  -H "Content-Type: application/json" \
  -d '{"cidr":"10.20.0.0/24","name":"Paris DC1 servers"}'

A body that fails validation comes back with issues — the field path and what was wrong with it — because the caller is a script, and a script cannot act on "invalid request" alone.

A scan can ask for nmap, if the daemon that runs it has one:

bash
curl -X POST "https://loomscope.example.com/api/v1/scans" \
  -H "Authorization: Bearer lsk_..." \
  -H "Content-Type: application/json" \
  -d '{"networkId":"…-uuid","nmap":{"enabled":true,"scripts":["default","safe"]}}'

scripts takes NSE categories — default, safe, version, vuln — never script names or paths. nmap's external category is excluded from whatever you ask for: 46 scripts it classes as safe also query third parties such as robtex and public DNS blacklists, and "safe" there means "will not crash the target", not "stays on your network".

Asking for nmap on a daemon that has none is a 409, not a 202 for a scan that quietly runs without it — a smaller result with a success code on it reads as a smaller network. Loomscope never ships nmap; install it on the daemon's host and the capability appears when the daemon next registers.

Three refusals are worth knowing in advance:

  • A scan with no daemon online for that site is a 409, not a queued session. A session nothing can run sits in the UI looking like work in progress.
  • A network whose cidr has bits set below the prefix is a 400. 10.0.0.1/24 is refused rather than truncated to 10.0.0.0/24 — a silently widened range is a scan of 254 machines nobody asked for.
  • reason: "scheduled" on a snapshot is a 400. It means the nightly job took that one, and an API call did not.

Every write leaves an audit-log row carrying the key that acted, not a user: the key acted, and whoever minted it may have been asleep. The key row carries the user, so "who" is still answerable one join away, without the log asserting something it cannot know.

Topologies are read-only here. Regenerating one is a UI action, because it discards hand-placed node positions.

Scopes

ScopeGrantsNeeds at least
inventory:readReading the inventoryviewer
metrics:readThe Prometheus endpointviewer
inventory:writeAcknowledging findings, editing hostseditor
scan:runEnqueuing a scaneditor
scim:manageThe SCIM 2.0 surfaceadmin

A key never grants more than the membership behind it currently has. The role is resolved on every request rather than trusted from the key, so lowering somebody to viewer takes their key's write power away at that moment — not at its expiry.

Errors

RFC 9457 problem details, served as application/problem+json:

json
{
  "type": "https://loomscope.dev/reference/rest-api/#forbidden",
  "title": "Forbidden",
  "status": 403,
  "code": "forbidden",
  "detail": "This key does not carry the `inventory:read` scope.",
  "requestId": "0f8c1e2a-...",
  "requiredScope": "inventory:read"
}

Branch on code, never on detail. The code is stable; the prose is not. requestId also comes back as X-Request-Id, so a report from outside the deployment can be matched to a line in its logs.

Pagination

Keyset, not offset. Pass the nextCursor of one response as the cursor of the next; a null nextCursor means the last page. A page costs the same at the first row and the hundred-thousandth, and a host added mid-pagination cannot shift an existing row across a boundary and hide it.

An out-of-range limit is refused, not clamped — a caller asking for 1000 and silently receiving 200 would page as though it received 1000 and skip four fifths of the estate.

Timestamps

Every field ending in At is RFC 3339 with an explicit offset: 2026-08-14T13:51:09.385889+00:00. The document marks them format: date-time, so a generated client parses them as dates rather than strings.

Authentication

SurfaceCredential
the six collectionsAuthorization: Bearer <API key> (lsk_), scoped
/api/v1/daemon/*Authorization: Bearer <daemon key> plus X-Daemon-ID after registration
/api/scim/v2/*Authorization: Bearer <SCIM token>
/api/v1/admin/*Session cookie, admin or owner
/api/healthNone

API keys are hashed at rest and CSRF-exempt; cookie-authenticated state-changing endpoints require a CSRF token.

Every endpoint derives the organisation from the credential. A client-passed organisation id is never trusted, anywhere.

Health

http
GET /api/health
json
{ "ok": true, "ts": "2026-08-18T09:12:00.000Z" }

Returns non-200 when the database round trip fails. The server image uses it as its HEALTHCHECK.

Daemon protocol

Five endpoints, all initiated by the daemon. Nothing ever connects towards a daemon.

Register

http
POST /api/v1/daemon/register
Authorization: Bearer lsk_...
json
{
  "name": "paris-dc1",
  "version": "0.13.1",
  "os": "linux",
  "arch": "amd64",
  "capabilities": { "ebpf": false, "netflow": true },
  "networkCidrs": ["10.0.0.0/24"],
  "bootId": "3f2a...-uuid",
  "siteCode": "paris-dc1",
  "metadata": {}
}
json
{
  "daemonId": "8c1e...-uuid",
  "config": {
    "heartbeatIntervalSeconds": 30,
    "jobPollTimeoutSeconds": 30
  }
}

Registration is idempotent by name within the organisation. A daemon that restarts re-registers and keeps its identity — which is why two daemons sharing a name is a configuration error that shows up as hosts flickering.

siteCode binds the daemon to a site on first registration only. A site assigned in the UI always wins.

Heartbeat

http
POST /api/v1/daemon/heartbeat
Authorization: Bearer lsk_...
X-Daemon-ID: 8c1e...-uuid
json
{
  "status": "online",
  "loadedSignatures": { "core": 11, "custom": 3 }
}

Every 30 seconds by default. status is online or degraded; anything else is recorded as degraded. The signature counts are what let the UI confirm a custom YAML file actually loaded.

Poll for a job

http
POST /api/v1/daemon/jobs/poll
Authorization: Bearer lsk_...
X-Daemon-ID: 8c1e...-uuid
json
{ "waitSeconds": 30 }

A long poll, capped at 60 seconds. The control plane answers immediately if a scan is already queued; otherwise it waits on a PostgreSQL LISTEN channel dedicated to that daemon, so a scan enqueued in the UI reaches the daemon in milliseconds without anyone polling in a tight loop.

Responses:

json
{
  "kind": "network",
  "sessionId": "…-uuid",
  "target": {
    "cidr": "10.0.0.0/24",
    "excludeCidrs": ["10.0.0.5/32"],
    "topPortsTcp": 1000,
    "topPortsUdp": 50,
    "nmap": { "enabled": false, "scripts": [] }
  }
}

nmap.enabled is what this daemon should actually do, not what the caller asked for. A job queued yesterday may reach a daemon replaced since, so the control plane checks the daemon's reported capabilities again at hand-over and answers false where there is no nmap. A daemon never has to decide what to do about being asked for something it does not have.

json
{ "kind": "cancel", "sessionId": "…-uuid" }

An empty body means nothing was queued before the wait expired — poll again.

Behind a reverse proxy this endpoint needs a read timeout above 60 seconds, or the long poll becomes a reconnect loop. See TLS and reverse proxy.

Observations

http
POST /api/v1/daemon/observations
Authorization: Bearer lsk_...
X-Daemon-ID: 8c1e...-uuid
json
{
  "sessionId": "…-uuid",
  "final": false,
  "hosts": [ … ],
  "ips": [ … ],
  "ports": [ … ],
  "services": [ … ],
  "neighbors": [ … ],
  "vlans": [ … ],
  "routes": [ … ],
  "lagGroups": [ … ],
  "tls": [ … ],
  "flows": [ … ],
  "stats": {
    "hostsScanned": 254,
    "portsScanned": 254000,
    "durationMs": 21400,
    "hostsTotal": 37
  }
}
json
{ "accepted": 412, "dedupHits": 88 }

Batches stream while a scan runs, with final: true on the last one. The daemon never writes to the inventory — it posts observations and the control plane reconciles them, which is why a partial, duplicated or interrupted scan cannot corrupt anything.

stats.hostsTotal is the count of hosts the sweep found, sent from the first batch onward. It is the denominator the progress bar needs; the size of the target range is not, since most of a range never answers.

Flows

http
POST /api/v1/daemon/flows
Authorization: Bearer lsk_...
X-Daemon-ID: 8c1e...-uuid
json
{
  "flows": [
    {
      "bucketStart": "2026-08-22T09:05:00Z",
      "srcIp": "10.42.0.5",
      "dstIp": "10.42.0.16",
      "dstPort": 6379,
      "protocol": "tcp",
      "packets": 12,
      "bytes": 3456,
      "source": "netflow"
    }
  ],
  "dropped": 0
}
json
{ "accepted": 1 }

Separate from observations, and carrying no session id, because none of this belongs to a scan: the control plane resolves a flow by (dstIp, dstPort) against the inventory, never by which scan reported it.

Flows used to travel only inside an observation batch, which tied a continuous collector to a periodic scan. A daemon then held an hour of traffic in a fixed buffer and discarded what did not fit — measured on a live cluster, 18 728 buckets an hour to deliver 10 000.

dropped is how many buckets the daemon's buffer discarded since its last successful delivery. It accumulates on the daemon row and appears under Settings → Daemons. A dependency map built from what survived is partial, and this is the only thing that says so.

Each conversation should arrive once, pointing client → server. An exporter reports both halves, and the reply — addressed to the client's ephemeral port — matches no service; the daemon folds it into the request before sending, so bytes and packets are the whole conversation.

Observation types

ArrayCarries
hostsFingerprint, hostname, FQDN, vendor, OS, device type, interfaces, parent
ipsHost fingerprint, address, family (4 or 6)
portsFingerprint, address, protocol, number, state, banner
servicesSignature id and source, name, version, product, vendor, CPE, confidence, evidence
neighborsLLDP/CDP: local interface, remote chassis, port, system name, management address
vlansVLAN id, name, tagged and untagged membership
routesDestination, next hop, interface, metric
lagGroupsAggregation groups and their members
tlsCertificate chain, subject, issuer, validity, fingerprint
flowsSource, destination, ports, protocol, bytes, packets

A malformed batch returns 400 with the validation issues. The server logs the failing shape — field names and array lengths — never the values, since observations can carry banner bytes.

Administrative endpoints

Session-authenticated, admin or owner.

EndpointMethodPurpose
/api/v1/admin/snapshotPOSTTake an inventory snapshot now
/api/v1/admin/cloud/{id}/syncPOSTSync one cloud account now
/api/v1/admin/dependency-tickPOSTRebuild application-topology edges from flows
/api/v1/topology/regeneratePOSTRegenerate topology views
/api/v1/topology/{id}/positionsPUTSave node positions
/api/v1/topology/{id}/sharePOSTCreate or revoke a read-only share link
/api/v1/topology/{id}/export/{format}GETExport as mermaid or drawio
/api/v1/snapshots/{id}/attestation.pdfGETPDF attestation of a snapshot
/api/v1/ai/chatPOSTAssistant, when a provider is configured

Server-Sent Events

http
GET /api/sse

A live stream of inventory changes for the current organisation, fed by PostgreSQL LISTEN/NOTIFY. This is what makes the host list fill while a scan runs.

Any proxy in the path must have response buffering off, or events arrive in one lump when the stream closes.

Errors

StatusMeaning
400Body failed validation. issues carries the detail
401Missing, wrong or revoked credential
403Authenticated but not permitted
404No such resource in your organisation
409Conflict — a duplicate that must not be silently upserted
429Rate limited

A 404 rather than a 403 for another organisation's resource is deliberate: confirming that an id exists somewhere else is itself a disclosure.

Generating a client

bash
make openapi    # Zod → OpenAPI 3.1
make oapi-go    # OpenAPI → Go client

The spec is generated, never hand-written, so a client generated from it matches what the server actually validates.