> Source: https://nexusatlas.dev/docs/1.0.0/api — Nexus Atlas developer documentation (Reference). Converted from the HTML page; the page is canonical. This is the 1.0.0 release line; the current line is 3.0.0 (https://nexusatlas.dev/docs/api).

# REST API

`atlasd web -i <iface>` serves the dashboard and a JSON API on `0.0.0.0:9800`. The API is the same state every other tool reads — one snapshot, many doors — plus the mutating endpoints that make configuration changes safe to perform remotely. Every endpoint takes an optional `?interface=` parameter on multi-daemon hosts, and unknown `/api` paths return JSON `404 {"error":"not_found"}`, never a page. This page documents api contract 1, the number `/api/health` reports under `contracts.api`: it rises when an endpoint is removed or a field changes meaning, while a new endpoint or an added field leaves it where it is.

## The auth model in one minute

- **No accounts configured:** reads are open, every mutating endpoint answers `403 auth_not_configured`. Safe by default on a bench; create accounts before exposing the port.

- **Accounts:** username/password (argon2id) with optional WebAuthn passkeys — enrolling a passkey makes second-factor mandatory for that account. Sessions are signed HttpOnly cookies; login is rate-limited.

- **Protection posture:** `[web] protect = "writes"` (default) gates mutations only; `protect = "all"` gates reads too — the dashboard then shows a login screen and makes zero data requests before auth.

- **CSRF:** every mutating request carries the header `X-Atlas-CSRF: 1` (and a matching `Origin` when a browser sends one).

- **TLS:** `[web] tls = true` serves a locally generated certificate — wanted in practice for passkeys.

## Machine credentials

Two non-interactive credentials, deliberately different in power:

|  | Node token | API token |
|--|------------|-----------|
| Header | X-Atlas-Node-Token: … | Authorization: Bearer atlas_… |
| Minted by | atlasd config node-token — derived from the mesh secret, time-windowed, stateless | atlasd web token add <name> --scope read|write [--expires 30d] — secret printed once |
| Scope | Read-only, whole paths only: /api/stats, /api/config, /api/health, metrics, crash listing. Never a write, never /api/auth. | read = all GETs; write adds exactly the config-apply lifecycle, PUT /api/qos, PUT /api/position, enrolment, the admission writes, POST /api/fleet/revoke, /api/fleet/select-area, /api/fleet/zeroize and /api/fleet/update-class; the rotation writes take a session only. /api/auth/* is unreachable for any token. |
| Meant for | Fleet members reading each other under protect = "all"; scrape credentials. | CI, dashboards, automation acting as an operator. |

Fan-out endpoints (`/api/fleet/*`, support bundles) are deliberately excluded from node-token scope — a low-power credential must not be able to amplify itself across a fleet.

**In a record fleet** (`[auth] provider = "fleet"`) the node token is node-bound: `n1.` + base64url(node id ‖ window ‖ Ed25519 signature by the minting node's `signing_key`), verified against that node's record. Its `dashboard` flag decides the reach — `fleet` reads any member, `own` only the node that minted it, `none` nothing — and mesh-secret tokens are not accepted. `atlasd config node-token` mints the right form for the fleet it runs in. See [capability flags](https://nexusatlas.dev/docs/1.0.0/concepts/identity#capabilities).

## Read endpoints

### GET `/api/stats`
The full per-interface snapshot: links (RTT, jitter, loss, capacity, quality, counters, quota), peers and their per-link views, mesh membership, routes, topology with node roles and positions, crypto posture, bonding-tax ledger, the `terrain` block when `[terrain]` is configured (see [reading it](https://nexusatlas.dev/guides/terrain#snapshot)), and the control plane's `security` block (`protocol`, `lsa_sig_rejected`, `gossip_sig_rejected`, `evicted_total`, `handshake_rate_limited`, `handshake_cookie_sent`, `mesh_secret`), and the [release block](https://nexusatlas.dev/docs/1.0.0/api#stats-release) with each peer's negotiated capabilities.

Request

```
curl -s http://node-a:9800/api/stats
```

Response — abbreviated

```
{
  "interface": "nexus0",
  "address": "10.0.100.1/24",
  "uptime_secs": 5312.4,
  "strategy": "lowest_delivery_time",
  "probe_interval_ms": 250,
  "dead_probe_count": 5,
  "identity": {
    "node_name": "node-a",
    "node_role": null,
    "listen_port": 51820,
    "mtu": 1416,
    "public_key_short": "8b4c..901a"
  },
  "release": {
    "version": "1.0.0",
    "build": "4d54d40",
    "contracts": { "wire": 3, "relay": 2, "seal": 2, "pairing": 3, "adapter": 1, "report": 2, "api": 1 },
    "caps": ["understood", "qos", "fec", "bundle", "seal", "piggyback"]
  },
  "protocol_refused": 0,
  "links": [
    {
      "id": 0,
      "name": "wired",
      "bind_device": "eth0",
      "alive": true,
      "rtt_ms": 0.42,
      "jitter_ms": 0.05,
      "loss_pct": 0.0,
      "capacity_mbps": 100.0,
      "quality_score": 0.98,
      "tx_bytes": 104857,
      "rx_bytes": 99231,
      "quota": null
    }
  ],
  "peers": [
    {
      "id": 0,
      "public_key_short": "6324..7876",
      "tunnel_ips": ["10.0.100.2"],
      "session_established": true,
      "last_handshake_unix": 1786297090.9,
      "caps": ["understood", "qos", "fec", "bundle", "piggyback"],
      "links": [ { "link_name": "wired", "alive": true, "rtt_ms": 0.4 } ]
    }
  ],
  "mesh": { "enabled": false, "member_count": 0 },
  "routes": [],
  "total_tx_bytes": 1048576,
  "total_rx_bytes": 992310
}
```

### GET `/api/stats`
Release, contracts and capabilities — what [atlasd --version](https://nexusatlas.dev/docs/1.0.0/cli#cmd-version) prints and what the dashboard's About card on the Settings tab shows. `release` carries `version` (x.y.z), `build` (the git short hash, or `unknown`), `contracts` (`wire`, `relay`, `seal`, `pairing`, `adapter`, `report`, `api`; `report` is absent for an embedder that sends no console reports) and `caps`, the [capabilities](https://nexusatlas.dev/docs/1.0.0/concepts/architecture#capabilities) this node advertises in every handshake. Each `peers[]` entry carries `caps`: the names that peer advertised in the handshake behind its current session (`bit<N>` for one this build does not know), or `null` until a handshake has completed. A feature this node has and the peer lacks is negotiated off towards that peer, and these counters show where:

- `protocol_refused` — datagrams refused because their protocol byte names wire 1 or 2: a node accepts wire 3 only (the relay servers accept 3 and 2). Non-zero means a peer speaking another wire is trying to reach this one, and the two will not establish until every node of the fleet runs the same wire; its endpoint and version are logged once and journaled as `peer.protocol_refused`.

- `e2e.unsealed_no_cap` — unicast sent hop-encrypted only, because the destination does not advertise `seal`. Always zero under `e2e_require`, which never downgrades.

- `multicast.child_without_cap` — tree branches skipped because the child does not advertise `multicast`.

- `interconnect[].dropped_no_cap` — seam egress refused because the far daemon on that seam does not advertise `interconnect`.

### GET `/api/interfaces`
Array of daemon instances on this host.

Request

```
curl -s http://node-a:9800/api/interfaces
```

Response

```
["nexus0"]
```

### GET `/api/health`
Liveness verdict — `200` healthy / `503` not — with named checks: daemon alive, data path genuinely moving, links probed on cadence, peers established. Built for external monitors: `curl -fsS …/api/health` is a complete probe. Beside `version` it names the `build` (git short hash) and every `contracts` number the daemon speaks, so a monitor can flag version skew across a fleet from the same probe.

Request

```
curl -fsS http://node-a:9800/api/health
```

Response — 200 when healthy, 503 otherwise

```
{
  "ok": true,
  "interface": "nexus0",
  "version": "1.0.0",
  "build": "4d54d40",
  "contracts": { "wire": 3, "relay": 2, "seal": 2, "pairing": 3, "adapter": 1, "report": 2, "api": 1 },
  "process":   { "running": true, "pid": 1234, "uptime_secs": 5312.4 },
  "data_path": { "ok": true, "tun_present": true, "engine_advancing": true },
  "links":     { "total": 2, "alive": 2, "probed_recently": 2 },
  "peers":     { "total": 1, "established": 1 },
  "watchdog":  { "systemd": true, "interval_ms": 30000, "tripped": false },
  "checks": [
    { "name": "daemon",    "ok": true, "required": true,
      "detail": "stats snapshot 0.4s old" },
    { "name": "data_path", "ok": true, "required": true,
      "detail": "TUN present, engine advancing, links probed, traffic flowing" },
    { "name": "links",     "ok": true, "required": true,
      "detail": "2/2 alive, 2 probed within cadence" },
    { "name": "peers",     "ok": true, "required": false,
      "detail": "1/1 sessions established" }
  ]
}
```

### GET `/api/journal`
The persistent event journal, newest first, cursor-paged. Filters: `since`, `until`, `category`, `severity`, `code`, `limit`.

Request

```
curl -s "http://node-a:9800/api/journal?category=link&severity=warn&since=1h"
```

Response

```
{
  "interface": "nexus0",
  "schema_version": 1,
  "entries": [
    {
      "v": 1,
      "seq": 891,
      "ts": 1786297001,
      "category": "link",
      "severity": "warn",
      "code": "link.down",
      "msg": "link wired on nexus0 went down",
      "fields": { "reason": "probe_timeout", "missed_probes": 5 }
    }
  ],
  "count": 1,
  "next_cursor": "890"
}
```

### GET `/api/scheduler/log`
Ring buffer of recent scheduler decisions — which link won each placement and at what cost. The fastest way to understand why traffic moved.

### GET `/api/netcheck`
The NAT classification report from the [traversal netcheck](https://nexusatlas.dev/docs/1.0.0/concepts/traversal#netcheck). The relay directory and federation add their own blocks to `/api/stats` — `traversal.directory`, `traversal.federation`, `links[].relay.via`, `peers[].directory` — listed on [Relay protocol](https://nexusatlas.dev/docs/1.0.0/relay-protocol#snapshot).

GET/api/config · /api/config/schema
The live configuration with secrets replaced by `__ATLAS_REDACTED__`; and the JSON Schema the daemon generates from its own structs — the same document behind the [configuration reference](https://nexusatlas.dev/docs/1.0.0/config).

Request

```
curl -s http://node-a:9800/api/config/schema
```

Response — abbreviated

```
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Nexus Atlas config",
  "type": "object",
  "required": ["interface", "link"],
  "properties": {
    "interface": { "$ref": "#/definitions/InterfaceConfig" },
    "link":      { "type": "array", "items": { "$ref": "#/definitions/LinkConfig" } },
    "peer":      { "type": "array", "items": { "$ref": "#/definitions/PeerConfig" } },
    "scheduler": { "allOf": [ { "$ref": "#/definitions/SchedulerConfig" } ] }
  },
  "definitions": { "InterfaceConfig": { }, "LinkConfig": { } }
}
```

### GET `/metrics`
Prometheus/OpenMetrics exposition for every interface on the host. On by default; follows the node's protection posture like any read.

Request

```
curl -s http://node-a:9800/metrics
```

Response — excerpt

```
# HELP atlas_link_up Link liveness (1 = alive)
# TYPE atlas_link_up gauge
atlas_link_up{interface="nexus0",link="wired"} 1
atlas_link_up{interface="nexus0",link="wireless"} 1
atlas_link_rtt_seconds{interface="nexus0",link="wired"} 0.00042
atlas_link_tx_bytes_total{interface="nexus0",link="wired"} 104857
atlas_peer_last_handshake_seconds{interface="nexus0",peer="6324..7876"} 1.7862970909e+09
```

GET/api/terrain · /api/terrain/grid
The elevation tiles the running daemon loaded under [[terrain]](https://nexusatlas.dev/docs/1.0.0/config#cfg-terrain), and the grid the dashboard's Terrain tab draws. `/api/terrain` returns `loaded`, `prediction`, each tile (`name`, `rows`, `cols`, `bounds` as south, west, north, east, `cell_m`, `min_m`, `max_m`, `bytes`), the union `bounds`, the finest `cell_m`, `load_errors`, `grid_max` (512) and `grid_default` (256); with nothing loaded, `loaded: false`, `configured`, `load_errors` and a `hint` on how to load a tile. `/api/terrain/grid` takes `bbox=S,W,N,E` (the tiles' extent when absent) and `size` (2–512, default 256, samples on the longer side) and returns `application/octet-stream`: a resampled `.nadt` grid (48-byte header, little-endian `i16` rows, −32768 where no tile covers a cell), never finer than the finest tile, with an `ETag` and an `X-Terrain-Grid: <rows>x<cols>` header. `If-None-Match` with the same tag is a `304`; a malformed box is a `400`, a box off the tiles a `422`, no tiles a `404` with the hint. Both read under the same gate as `/api/stats` and are not in the mesh node-token scope. See [Load elevation data onto a node](https://nexusatlas.dev/guides/terrain).

Request

```
curl -s 'http://node-a:9800/api/terrain?interface=atlas0'
curl -s -D - -o grid.nadt 'http://node-a:9800/api/terrain/grid?interface=atlas0&bbox=42.12,24.02,42.18,24.12&size=256'
```

Response — abbreviated

```
{"interface":"atlas0","loaded":true,"prediction":true,
 "tiles":[{"name":"ao.nadt","rows":362,"cols":506,"bounds":[42.1,24.0,42.2003,24.1403],"cell_m":30.87,…}],
 "bounds":[42.1,24.0,42.2003,24.1403],"cell_m":30.87,"load_errors":[],"grid_max":512,"grid_default":256}

HTTP/1.1 200 OK
content-type: application/octet-stream
etag: "t5c0e…"
x-terrain-grid: 207x256
```

GET/api/qos · /api/position · /api/crash · /api/mode · /api/auth/status
QoS policy document; node position; locally spooled crash reports; shell mode probe; and the anonymous auth-posture probe a client uses to decide whether to show a login.

### GET `/api/identity · /api/fleet`
This node's identity as the running daemon resolved it — `mode` (`fleet` or `keys`), node id, record UUID and name, each key of its record with validity and whether it is held here, its capability flags and the fleet list it holds — plus the counters the flags and the routing rules move: `refused_no_record`, `fleet_lists_rejected`, `caps_counters.{relay_refused, class_clamped, class_clamped_transit, origin_unrecorded, sink_dropped, sink_local_dropped}`, `lsa_no_session`, `lsa_one_sided_links`, and `revoked_by` on a node that was removed. `/api/fleet` returns the verified fleet list, records keyed by node id with flags and key sets, its `revoked` entries, each record's `geofence` and the list's `commands[]`, re-verified from disk on every request (`{"mode":"keys"}` without one). See [Identity and authorisation](https://nexusatlas.dev/docs/1.0.0/concepts/identity).

Request

```
curl -s http://node-a:9800/api/identity
```

Response — abbreviated, a node mid-rotation

```
{
  "interface": "atlas0",
  "mode": "fleet",
  "node_id_short": "e191..81b6",
  "node_uuid": "af2e580d-5365-4b5f-acfa-9a9da1f69e73",
  "record_name": "drone",
  "transport_key_id": "5123339a",
  "next_transport_key_id": "6142ed49",
  "keys": [
    { "kind": "transport", "key_id": "6142ed49", "valid_from": 0, "valid_to": 0, "valid_now": true, "held": true },
    { "kind": "transport", "key_id": "5123339a", "valid_from": 0, "valid_to": 0, "valid_now": true, "held": true },
    { "kind": "signing",   "key_id": "e5c003f3", "valid_from": 0, "valid_to": 0, "valid_now": true, "held": true }
  ],
  "capabilities": { "relay": true, "classes": ["Control", "Voice", "Position", "Telemetry", "Video", "Bulk"],
                    "config_author": false, "dashboard": "own" },
  "fleet": { "fleet": "field-1", "seq": 2, "hash8": "ffc955e8", "records": 4, "signed_by": "operator" },
  "refused_no_record": 0,
  "fleet_lists_rejected": 0,
  "caps_counters": { "relay_refused": 0, "class_clamped": 0, "class_clamped_transit": 0, "origin_unrecorded": 0,
                     "sink_dropped": 0, "sink_local_dropped": 0 },
  "lsa_no_session": 0,
  "lsa_one_sided_links": 0
}
```

### GET `/api/config/rollout · /api/config/rollouts`
The staged rollout this node is driving — record, release gate, canary progress and the live timeline of every node's status changes (`rollout` is null on a node that started none) — and the archive of finished rollouts, newest first; `/api/config/rollouts/<rollout_id>` returns one archive with its timeline. See [following a push](https://nexusatlas.dev/docs/1.0.0/concepts/fleet-config#propagation).

Request

```
curl -s http://node-a:9800/api/config/rollout
```

Response — abbreviated

```
{
  "rollout": {
    "rollout_id": "r-1791695967-1f47ff58",
    "phase": "released",
    "canary_nodes": ["2244..c218"],
    "canary_epoch": 1,
    "final_epoch": 2,
    "timeline": [
      { "at": 1791695985, "node_id_short": "9edd..0553", "state": "received", "via": "self", "epoch": 2 },
      { "at": 1791695989, "node_id_short": "575a..b158", "state": "received", "via": "9edd..0553", "epoch": 2 },
      { "at": 1791695995, "node_id_short": "2244..c218", "state": "received", "via": "575a..b158", "epoch": 2 }
    ]
  },
  "gate": { "total": 1, "confirmed": 1, "failed": 0, "pending": 0, "silent": 0,
            "satisfied": true, "blocked": false, "expired": false, "reason": "…" },
  "canaries": [ … ]
}
```

The archive list

```
curl -s http://node-a:9800/api/config/rollouts
```

```
{ "rollouts": [ { "rollout_id": "r-1791695967-1f47ff58", "phase": "released",
    "started_at": 1791695967, "finished_at": 1791695983, "canary_epoch": 1, "final_epoch": 2,
    "nodes": 3, "events": 13, "closed": true } ] }
```

### GET `/api/admission · /api/admission/pending · /api/admission/invites`
The snapshot's `admission` block (see [Admission modes](https://nexusatlas.dev/docs/1.0.0/concepts/identity#admission)): `pending_enabled`, `psk` (configured or not, never the key), `can_sign`, `pending` knocks, `blocked` keys, `invites` without their tokens (state, uses left, who minted it, the nodes it `admitted`), `local` records held outside the signed list (and whether they are `queued` for an author, with each one's `forward` state: status, attempts, author, reason), `queued`, `accept_forwarded`, `held` (records other nodes forwarded to this author, waiting for a decision), `certified` records and `counters` (`invited`, `token_refused`, `cert_accepted`, `cert_refused`, `psk_dropped`, `knocks`, `knocks_limited`, `approved`, `denied`, `blocked_dropped`, and for forwarding `forwarded`, `forward_unreachable`, `forward_accepted`, `forward_held`, `forward_refused`, `forward_listed`, `intake_accepted`, `intake_held`, `intake_refused`, `intake_rejected`). `/pending` returns `{enabled, pending_max, pending, blocked, held, accept_forwarded}`; `/invites` returns `{can_sign, invites}`.

Request

```
curl -s http://hub-a:9800/api/admission/pending
```

Response — one knock waiting

```
{
  "interface": "atlas0",
  "enabled": true,
  "pending_max": 200,
  "pending": [
    { "id": "5d2e81a0", "key": "…", "source": "198.51.100.23:51820", "claimed_name": "phone-07",
      "uuid": null, "first_seen": 1791804903, "last_seen": 1791804911, "count": 4 }
  ],
  "blocked": []
}
```

## Write endpoints

### POST `/api/config/validate`
Runs the daemon's exact load pipeline over posted TOML — the same findings `atlasd config check` prints, without touching the running state.

Request — session-gated (not reachable via API tokens)

```
curl -X POST -b "$SESSION" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"toml": "[interface]\nname = \"nexus0\"\n..."}' \
     http://node-a:9800/api/config/validate
```

Response — ok is false only on fatal findings

```
{
  "ok": false,
  "findings": [
    {
      "fatal": true,
      "path": "peer[0].allowed_ips[0]",
      "message": "invalid CIDR: \"10.0.100.2/33\""
    },
    {
      "fatal": false,
      "path": "scheduler.priority_order",
      "message": "unknown link name \"wierd\" is ignored"
    }
  ]
}
```

### POST `/api/config/stage → commit → confirm | revert`
The commit-confirmed apply lifecycle: stage a config, commit it, and *confirm within the window* (default 90 s, configurable 5 s–24 h) — or the node reverts itself. The question "can I brick a remote node with a config push?" is answered by this machinery: no, it un-bricks itself. `GET /api/config/apply/status` reports the state machine.

1 — Stage the new config

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"toml": "[interface]\n...", "confirm_secs": 90}' \
     http://node-a:9800/api/config/stage
```

```
{
  "stage_id": "9f2c41d3a8b0",
  "diff": [ "…unified diff against the live config…" ],
  "hot": false,
  "restart": true
}
```

2 — Commit; the daemon re-validates, applies and restarts in place

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"stage_id": "9f2c41d3a8b0"}' \
     http://node-a:9800/api/config/commit
```

```
{
  "state": "awaiting_confirm",
  "stage_id": "9f2c41d3a8b0",
  "deadline": 1786297180,
  "auto_confirm": "manual"
}
```

3 — Confirm inside the window (or the node reverts itself)

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     http://node-a:9800/api/config/confirm
```

```
{ "state": "committed", "stage_id": "9f2c41d3a8b0" }
```

A missed window instead reports

```
{ "state": "reverted", "reason": "timeout" }
```

PUT/api/qos · /api/position
Merge-update the live QoS policy (applied within the reload cadence, no restart); operator-inject a position for nodes without a feed.

Request — thicken Video's FEC; other classes keep their persisted policy

```
curl -X PUT -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"classes": [{
           "class": "Video",
           "reliability": "DeadlineBounded",
           "multipath": { "kind": "Fec", "overhead_pct": 25 },
           "priority": 2,
           "latency_budget_ms": 200
         }]}' \
     http://node-a:9800/api/qos
```

Response — the full merged policy view, abbreviated

```
{
  "classes": [
    {
      "class": "Video",
      "reliability": "DeadlineBounded",
      "multipath": { "kind": "Fec", "overhead_pct": 25 },
      "priority": 2,
      "latency_budget_ms": 200
    },
    { "class": "Control", "...": "..." }
  ]
}
```

Position: request, then the injected + advertised view

```
curl -X PUT -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"lat_deg": 42.6977, "lon_deg": 23.3219, "alt_m": 590}' \
     http://node-a:9800/api/position
```

```
{
  "injected":   { "lat_deg": 42.6977, "lon_deg": 23.3219, "alt_m": 590.0 },
  "advertised": { "lat_deg": 42.6977, "lon_deg": 23.3219, "alt_m": 590.0 }
}
```

### POST `/api/config/push · /api/config/rollout/*`
Fleet configuration: sign the policy sections as the next epoch and distribute it mesh-wide, directly or as a staged canary rollout (start at a canary set, then release fleet-wide or abort).

Push — sign the live config's policy as the next epoch (toml optional)

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"confirm_secs": 90, "target_roles": ["relay"]}' \
     http://node-a:9800/api/config/push
```

```
{
  "epoch": 7,
  "hash8": "3d92c1aa",
  "targeted": { "roles": ["relay"] }
}
```

Canary rollout — phase 1 targets only the named nodes

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"canary_nodes": ["8b4c901a"], "confirm_secs": 120}' \
     http://node-a:9800/api/config/rollout/start
```

```
{
  "rollout_id": "r-8f31",
  "phase": 1,
  "canary_epoch": 8,
  "hash8": "5e07b2c4",
  "canary_nodes": ["8b4c901a"],
  "unknown_nodes": []
}
```

Then release fleet-wide, or abort — and the guard rails

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     http://node-a:9800/api/config/rollout/release

# 400 "empty_canary_set"   — a rollout targeting everyone is just a push
# 409 "rollout_in_flight"  — release or abort the current one first
```

### POST `/api/fleet/revoke`
The dashboard's **Remove nodes…** (see [Revocation](https://nexusatlas.dev/docs/1.0.0/concepts/identity#revocation)). Body `{"nodes": [uuid…], "reason"?, "dry_run"}`. A dry run previews the removal; the real run signs the next list with this node's config-author key and hands it to its daemon, which adopts and floods it. A signed-in session or an API token with write scope may call it, so the backend that admitted a node can remove it. A node cannot remove itself.

Preview

```
curl -X POST -b "$SESSION" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"nodes": ["5a1c3e70-8d2b-4f61-9c0e-2e7b44d19e02"], "reason": "lost on sortie 12", "dry_run": true}' \
     http://node-a:9800/api/fleet/revoke
```

```
{
  "ok": true,
  "preview": {
    "fleet": "field-1",
    "current_seq": 7,
    "next_seq": 8,
    "remove": [{ "node_id": "5a1c3e70-8d2b-4f61-9c0e-2e7b44d19e02", "name": "drone03", "node_id_short": "5a1c..9e02" }],
    "remaining": 5,
    "rotation": { "kind": "mesh_secret", "recipients": 5 },
    "warnings": []
  }
}
```

Sign and push — the same body with "dry_run": false

```
{ "ok": true, "seq": 8, "hash8": "9b0e7d21", "removed": [ … ], "pushed": true }

# 400 "unknown_node"        — no record with that node id
# 404 "not_a_record_fleet"  — the node runs without a fleet list
# 409 "no_author_key"       — this node's key cannot sign the list ("hint" says why)
# 422 "invalid"             — no nodes selected, or the node named itself
```

### POST `/api/fleet/select-area`
The Topology map's area tool (see [Remove nodes by area](https://nexusatlas.dev/guides/area-zeroize#select)). Body `{"polygon": [[lat, lon], …]}` or `{"geojson": …}` (`[lon, lat]`). Resolves the polygon against this node's last known positions and returns the review; it removes nothing — the removal is [/api/fleet/revoke](https://nexusatlas.dev/docs/1.0.0/api#fleet-revoke) with the node ids you confirmed. A session or a write-scope API token may call it.

Request

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"polygon": [[42.66, 23.28], [42.66, 23.38], [42.72, 23.38], [42.72, 23.28]]}' \
     http://node-a:9800/api/fleet/select-area
```

```
{
  "ok": true, "fleet": "field-1", "seq": 7,
  "inside": [
    { "node_id": "4c1e…", "node_id_short": "4c1e..a902", "name": "n3", "lat_deg": 42.6977, "lon_deg": 23.3219,
      "fix_age_secs": 1, "live": true, "is_self": false },
    …
  ],
  "outside": 2,
  "no_position": [{ "node_id": "e5a3…", "node_id_short": "e5a3..1b7c", "name": "n2", "is_self": false }],
  "polygon": [[42.66, 23.28], [42.66, 23.38], [42.72, 23.38], [42.72, 23.28]]
}

# 404 "not_a_record_fleet" — the node runs without a fleet list
# 422 "invalid"            — fewer than 3 or more than 256 corners, out of range, or winding around a pole
# 503 "no_stats"           — the daemon is not running
```

### POST `/api/fleet/zeroize`
The dashboard's **Zeroize…** (see [Wipe a node remotely](https://nexusatlas.dev/guides/area-zeroize#zeroize)). Body `{"node", "not_before"?, "revoke"?, "cancel"?, "dry_run"?}`. A dry run previews the next list; the real run signs the command — and, unless `"revoke": false`, the node's revocation — into the next list with this node's config-author key and hands it to its daemon, which floods it. `"cancel": true` drops a pending command. A node cannot zeroize itself remotely. A session or a write-scope API token may call it.

Preview

```
curl -X POST -b "$SESSION" -H "X-Atlas-CSRF: 1" -H "Content-Type: application/json" \
     -d '{"node": "n2", "dry_run": true}' http://node-a:9800/api/fleet/zeroize
{ "ok": true, "preview": { "fleet": "field-1", "current_seq": 8, "next_seq": 9,
  "command": { "node_id": "…", "node_id_short": "e5a3..1b7c", "name": "n2", "not_before": 0, … }, "can_sign": true } }
```

Sign and push — the same body with "dry_run": false

```
{ "ok": true, "seq": 9, "hash8": "4f7a2c10", "command": { … }, "pushed": true }

# 400 "unknown_node"        — no record or revoked node by that name or id
# 404 "not_a_record_fleet"  — the node runs without a fleet list
# 409 "no_author_key"       — this node's key cannot sign the list ("hint" says why)
# 422 "invalid"             — no node given, no pending command to cancel, or the node named itself
```

### POST `/api/admission/invites · invites/withdraw · approve · deny · unblock`
The admission lifecycle (see [Admit nodes by pairing code, certificate or approval](https://nexusatlas.dev/guides/admission-modes)). Each request goes to the daemon through a file beside its config — the daemon is the only writer of the admission store — and the API waits up to 5 s for its answer. The journal records the operator: `web:<user>` for a session, `token:<name>` for an API token; the server sets it, never the body. All five are on the write-token allowlist.

| Path | Body | Answer |
|------|------|--------|
| /api/admission/invites | {name, allowed_ips | pool, uses, ttl_secs, classes, relay, dashboard, roles, region} — ttl_secs defaults to 900, uses to 1 | {id, name, token, uri, uri_len, code: {version, hub_key, anchors, authors, address, interface_address, hub_address, region}, expires_at, uses, capabilities, allowed_ips, pool, can_sign}; the token is returned once; code spells out what the v=3 pairing code carries under the token's MAC (see what the code carries) |
| /api/admission/invites/withdraw | {id} (or its first 8 characters) | {id} |
| /api/admission/approve | {id, allowed_ips | pool, name, classes, relay, dashboard, roles}; for a forwarded record held on an author, {id} signs it exactly as minted | {id, name, node_id, allowed_ips, capabilities, signed, queued, adopted_seq} |
| /api/admission/deny | {id, block_secs, reason} | {id, blocked_until} |
| /api/admission/unblock | {id} | {id} |

Mint a pairing code from a backend

```
curl -X POST -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"name": "drone07", "allowed_ips": ["10.1.0.17/32"], "ttl_secs": 900}' \
     http://hub-a:9800/api/admission/invites
{ "ok": true, "id": "7c41e09a2f5d4b8e9a3c11d07f62b5e4", "name": "drone07", "token": "…",
  "uri": "atlas:pair?v=3&k=…&n=drone07&a=…&i=10.1.0.17/32&h=10.1.0.1/24&t=…", "uri_len": 202,
  "code": { "version": 3, "hub_key": "…", "anchors": ["…"], "authors": [], "address": "10.1.0.17/32",
            "interface_address": "10.1.0.17/24", "hub_address": "10.1.0.1/24", "region": null },
  "expires_at": 1791805500, "uses": 1,
  "capabilities": { … }, "allowed_ips": ["10.1.0.17/32"], "pool": null, "can_sign": true }

# 404 "unknown_invite" | "unknown_knock" | "unknown_block"
# 409 "not_a_record_fleet" — invitations need provider = "fleet" and a list
# 409 "refused"            — the key, UUID or signing key is revoked or taken
# 422 "invalid"            — a bad CIDR, no name, or both allowed_ips and pool
# 503 "daemon_unavailable" — the daemon did not answer within 5 s
```

## Fleet reads

`GET /api/fleet/configs` and `GET /api/fleet/health` fan out over mesh members through the tunnel and return per-node rows — including honest `reachable: false` entries for nodes that did not answer. Session-only by design.

## Fleet pushes and rotations

The release gate, update classes and rotation schedules are read and written here; the dashboard's Configure tab and `atlasd fleet rollout` / `fleet rotate` use the same calls. Writes sign with this node's config-author key (409 `no_author_key` otherwise) and take a session with the CSRF header; `update-class` also accepts a write-scope API token, the rotation writes do not.

### GET `/api/config/rollout`
The live rollout record. Beside the canary `gate`, a `release_gate` during and after a release watch: `{ epoch, deadline, required_total, required_confirmed, required_silent, required_failed, holding, stragglers, satisfied, nodes: [{ node_id_short, name, class, pinned, state, confirmed, failed, silent, epochs_behind }], reason }`; `phase` may be `complete`; `abort_cause` is `{ node_id_short, name, reason, phase }` after an automatic abort; `classes` lists every record's class.

### POST `/api/config/rollout/start`
Body gains `"release_watch_secs"`: this rollout's release deadline, counted from the release; omitted, `[config_mgmt] release_watch_secs` applies, and 0 means three confirm windows plus two minutes; given, it must be 10 to 604 800. `/release` and `/abort` are unchanged, except that an abort may also end a released rollout still waiting on a required node.

### POST `/api/fleet/update-class`
Body `{"node", "class": "required"|"best_effort"|"left_behind", "pinned"?, "dry_run"?}`, `node` a record name, UUID or short node id. Writes the record's class into the next fleet list, signs and pushes it; answers `{ ok, node, class, pinned, seq, hash8, pushed }`. The Fleet view's **Set class** action.

### GET `/api/fleet/rotations`
Every schedule in the list (`rotations[]`: `id`, `kind`, `subject`, `subject_name`, `next_key_id`, the three instants, `state`, `recipients`) with its retire `gate` — `required_total`, `required_on_new`, `holding`, `satisfied`, `reason`, every node's reported state in `nodes` and the nodes still on the old key or secret in `on_old` — plus `own` (this node's view from its snapshot), `nodes` with each record's class and lifeline flag, the `defaults` and `lifeline_secs` in force, and `can_sign`. The Configure tab's Rotations view.

Example

```
$ curl -sS -H "Authorization: Bearer $TOKEN" "http://gs1:9800/api/fleet/rotations?interface=atlas0" \
    | jq '.rotations[0] | {id, kind, state, gate: (.gate | {required_total, required_on_new, satisfied, holding}), on_old: [.gate.on_old[].name]}'
{
  "id": "rot-1791900000-mesh_secret",
  "kind": "mesh_secret",
  "state": "published",
  "gate": { "required_total": 2, "required_on_new": 2, "satisfied": true, "holding": [] },
  "on_old": ["drone-b", "drone-c"]
}
```

### POST `/api/fleet/rotations`
Plan a rotation. Body `{"kinds": ["mesh_secret"|"node_transport"|"node_signing", …], "subject"?, "next_key"?, "next_signing_key"?, "publish_in"?, "activate_after"?, "dry_run"?}` — several kinds plan together in one revision; `publish_in` is seconds from now (default 0), `activate_after` seconds from publish (default `[rotation] default_publish_to_activate_secs`); a mesh-secret plan mints the secret on this node and seals it to every record. Signs and pushes the list; answers `{ ok, planned: [{ id, kind, publish_at, activate_at, recipients, next_key_id }], seq, hash8, pushed }`.

### POST `/api/fleet/rotations/<id>/activate · retire · cancel`
Sign a revision: bring activation forward to now; set `retire_at` to now — 409 `gate_not_met` with the `gate` while a required node, or the subject, has not reported active, unless `{"force": true, "reason"}`, which is recorded as `rotation.retire_forced` and answered with `forced: true`; or cancel a rotation that has not activated.

### GET `/api/fleet`
Each record's capabilities now include `sink` (a read-only member, the [feed gateway](https://nexusatlas.dev/docs/1.0.0/concepts/feed-gateway) host), beside `update_class` and `lifeline`; a sink's compiled capabilities carry no relay, no classes and no authorship whatever the record lists. On any node, `/api/stats` counts `caps.sink_dropped`: data packets a sink originated that this node dropped (journalled `caps.sink_dropped` once a minute per sink). On the sink itself, `caps.sink_local_dropped` counts what its own TUN offered and it held back, and `multicast` carries `redundant_parents`, `redundant_honoured` (a sink asking for 2) and `alt_parent_accepted` (packets taken from the second parent).

### GET `/api/stats`
The clamp at every hop (see [capability flags](https://nexusatlas.dev/docs/1.0.0/concepts/identity#capabilities)): on any node that forwards relayed traffic, `caps.class_clamped_transit` counts packets whose class byte this node, as a transit hop, lowered to what the origin's record allows — the origin being the tunnel address in the 12-byte relay header — and `caps.origin_unrecorded` counts classified packets it dropped because no record covers that origin. On a destination running `[crypto] e2e`, `e2e.class_tampered` counts sealed packets that opened but arrived with a class above the one their sender sealed, or under another origin: a relay upgraded them or changed their sender, and they were dropped. The dashboard's Refusals row shows the three as clamped-in-transit, relayed-from-no-record and relay-tampered. `GET /api/identity` returns the same `caps` counters as `caps_counters`.

## Realistic examples

```
# External monitor: healthy exit or non-zero
curl -fsS http://node-a:9800/api/health

# Read a locked-down (protect = "all") fleet node over the tunnel
curl -H "X-Atlas-Node-Token: $(atlasd config node-token)" \
     http://10.0.100.2:9800/api/config

# CI: mint once, read forever (well, 30 days)
TOKEN=$(atlasd web token add ci --scope read --expires 30d)
curl -H "Authorization: Bearer $TOKEN" \
     "http://node-a:9800/api/stats?interface=nexus0"

# Change QoS policy from automation (write scope + CSRF header)
curl -X PUT -H "Authorization: Bearer $TOKEN_W" -H "X-Atlas-CSRF: 1" \
     -H "Content-Type: application/json" \
     -d '{"classes":[{"class":"Video","reliability":"DeadlineBounded",
          "multipath":{"kind":"Fec","overhead_pct":25},"priority":2}]}' \
     http://node-a:9800/api/qos
```

> **Serving the dashboard:** the browser UI is static files on disk (`/opt/atlas/web`), served by the same process. Without them the port is API-only — which is exactly what a headless integration wants.

## Building a client?

Official SDKs are planned, with a published OpenAPI document shipping first — the pipeline, language lineup and scope are laid out on the [SDKs page](https://nexusatlas.dev/docs/1.0.0/sdk). Until then, every response shape on this page is verified against the daemon source, and the [config schema](https://nexusatlas.dev/docs/1.0.0/config) is already machine-readable today.
