REST API
Collections, writes, one host's connections, scopes and problem details
Edit this pageOn 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.
| Collection | Filters beyond limit and cursor |
|---|---|
GET /api/v1/hosts | siteId, search |
GET /api/v1/services | hostId |
GET /api/v1/networks | siteId |
GET /api/v1/sites | — |
GET /api/v1/certificates | hostId, trustStatus |
GET /api/v1/vulnerabilities | serviceId, state, severity |
GET /api/v1/snapshots | siteId, reason |
GET /api/v1/topologies | siteId, 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:
GET /api/v1/hosts?search=lyo-app01
GET /api/v1/hosts?search=10.20.0.0/24A 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.
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.svgThe 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:
{
"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.
| Operation | Scope | Success |
|---|---|---|
POST /api/v1/networks | inventory:write | 201 |
POST /api/v1/scans | scan:run | 202 |
PATCH /api/v1/vulnerabilities/{id} | inventory:write | 200 |
POST /api/v1/snapshots | inventory:write | 201 |
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.
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:
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
cidrhas bits set below the prefix is a400.10.0.0.1/24is refused rather than truncated to10.0.0.0/24— a silently widened range is a scan of 254 machines nobody asked for. reason: "scheduled"on a snapshot is a400. 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
| Scope | Grants | Needs at least |
|---|---|---|
inventory:read | Reading the inventory | viewer |
metrics:read | The Prometheus endpoint | viewer |
inventory:write | Acknowledging findings, editing hosts | editor |
scan:run | Enqueuing a scan | editor |
scim:manage | The SCIM 2.0 surface | admin |
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:
{
"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
| Surface | Credential |
|---|---|
| the six collections | Authorization: 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/health | None |
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
GET /api/health{ "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
POST /api/v1/daemon/register
Authorization: Bearer lsk_...{
"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": {}
}{
"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
POST /api/v1/daemon/heartbeat
Authorization: Bearer lsk_...
X-Daemon-ID: 8c1e...-uuid{
"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
POST /api/v1/daemon/jobs/poll
Authorization: Bearer lsk_...
X-Daemon-ID: 8c1e...-uuid{ "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:
{
"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.
{ "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
POST /api/v1/daemon/observations
Authorization: Bearer lsk_...
X-Daemon-ID: 8c1e...-uuid{
"sessionId": "…-uuid",
"final": false,
"hosts": [ … ],
"ips": [ … ],
"ports": [ … ],
"services": [ … ],
"neighbors": [ … ],
"vlans": [ … ],
"routes": [ … ],
"lagGroups": [ … ],
"tls": [ … ],
"flows": [ … ],
"stats": {
"hostsScanned": 254,
"portsScanned": 254000,
"durationMs": 21400,
"hostsTotal": 37
}
}{ "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
POST /api/v1/daemon/flows
Authorization: Bearer lsk_...
X-Daemon-ID: 8c1e...-uuid{
"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
}{ "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
| Array | Carries |
|---|---|
hosts | Fingerprint, hostname, FQDN, vendor, OS, device type, interfaces, parent |
ips | Host fingerprint, address, family (4 or 6) |
ports | Fingerprint, address, protocol, number, state, banner |
services | Signature id and source, name, version, product, vendor, CPE, confidence, evidence |
neighbors | LLDP/CDP: local interface, remote chassis, port, system name, management address |
vlans | VLAN id, name, tagged and untagged membership |
routes | Destination, next hop, interface, metric |
lagGroups | Aggregation groups and their members |
tls | Certificate chain, subject, issuer, validity, fingerprint |
flows | Source, 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.
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/admin/snapshot | POST | Take an inventory snapshot now |
/api/v1/admin/cloud/{id}/sync | POST | Sync one cloud account now |
/api/v1/admin/dependency-tick | POST | Rebuild application-topology edges from flows |
/api/v1/topology/regenerate | POST | Regenerate topology views |
/api/v1/topology/{id}/positions | PUT | Save node positions |
/api/v1/topology/{id}/share | POST | Create or revoke a read-only share link |
/api/v1/topology/{id}/export/{format} | GET | Export as mermaid or drawio |
/api/v1/snapshots/{id}/attestation.pdf | GET | PDF attestation of a snapshot |
/api/v1/ai/chat | POST | Assistant, when a provider is configured |
Server-Sent Events
GET /api/sseA 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
| Status | Meaning |
|---|---|
| 400 | Body failed validation. issues carries the detail |
| 401 | Missing, wrong or revoked credential |
| 403 | Authenticated but not permitted |
| 404 | No such resource in your organisation |
| 409 | Conflict — a duplicate that must not be silently upserted |
| 429 | Rate 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
make openapi # Zod → OpenAPI 3.1
make oapi-go # OpenAPI → Go clientThe spec is generated, never hand-written, so a client generated from it matches what the server actually validates.