Machine Ingest API¶
The HTTP contract external producers use to push data into a deployment. NiFi flows,
an interop gateway, and force-tracking sources POST detections and targets to the web
tier; the web tier persists them and fans them onto the mesh. Ground truth is the web repo
(web/app/domains/api/, web/app/domains/api_credentials/, and the auth middleware).
This is a machine-to-machine boundary and a separate trust root from operator,
device, and server identity. Ingest credentials are HS256 JWTs verified against a database
registry; they do not ride an AuthEnvelope, are not Directory-signed, and are not
classification-gated. For principal identity (envelope, Ed25519, revocation) see
security/model.md; for trust roots see
security/pki.md.
Minting and revoking is an operator task
Issuing and revoking an ingest credential is an admin operation, not a self-service flow. See the runbook Issue an API credential.
Endpoints¶
Both endpoints are mounted outside the session / operationScope group: they are
cookie-less and CSRF-exempt. The real route prefix in code is /api/feed.
| Endpoint | Scope required | Purpose |
|---|---|---|
POST /api/feed/detections |
detections:write |
Push detection rows |
POST /api/feed/targets |
targets:write |
Push target rows |
There is no browser session on this boundary; the bearer token is the sole credential.
Authentication — HS256 bearer JWT¶
Every request carries Authorization: Bearer <JWT>.
- The JWT is HS256, signed with the deployment secret
WAYPOINT_API_JWT_SECRET. The algorithm is pinned to HS256 — there is nononedowngrade. - The header carries a
kid(key/credential id). Claims are{ scopes, ops, iat, exp? }. - The signature is the credential. No secret or hash is stored at rest — verification
is a signature check against the deployment secret, then a liveness lookup of
kidin the registry (below).
Authorization: Bearer <HS256 JWT>
header { alg: "HS256", kid: "<credential id>" }
claims { scopes: ["detections:write", ...], ops: ["*" | "<op id>", ...], iat, exp? }
Two authorization axes¶
Authorization is enforced on two independent axes:
scopes— the capability set (detections:write|detections:read|targets:write|targets:read). Enforced in the auth middleware: a token missing the scope the endpoint requires is rejected with 403 before the controller runs.ops— an operation-id allowlist.["*"]is account-wide; an empty list is deny-all. Enforced per row in the controller, because the operation id rides on the request body — each row names theoperation_idit targets, so the allowlist must be checked against the row, not the route.
Revocation registry¶
Live credentials are tracked in the api_credentials table. There is no secret column —
the row is metadata and revocation state only:
| Column | Meaning |
|---|---|
kid (unique) |
credential id named in the JWT header |
name |
human label |
scopes (jsonb) |
capability set baked into issued tokens |
ops (jsonb) |
operation-id allowlist |
created_by_id |
issuing operator |
expires_at |
optional hard expiry |
last_used_at |
last successful verify |
revoked_at |
set on revoke; NULL = live |
Verify path: signature check then kid must be live — present in the registry,
not revoked, not expired (the live lookup, findByKidLive).
Revocation stamps revoked_at. It is effective from the next request: the
signature still verifies (the secret is unchanged), but the live lookup fails, so the
credential is dead. There is no un-revoke — issue a fresh credential instead. Rotating
WAYPOINT_API_JWT_SECRET invalidates all outstanding tokens at once.
Request & response shape¶
Both endpoints take a batch (1–1000 rows) and are partial-success: good rows land even if some are rejected.
- Request:
Content-Type: application/json; body is{ "detections": [ … ] }or{ "targets": [ … ] }. - Success —
201 Createdwith a per-row outcome body. A batch with some bad rows is still201— the rejected rows are listed; only a whole-batch failure returns4xx.
// detections
{ "accepted": 2, "rejected": [ { "index": 2, "reason": "op_not_found" } ] }
// targets — also returns the new ids
{ "accepted": 1, "createdIds": [123], "rejected": [ … ] }
accepted is a count; each rejection is { index, reason } where index is the row's
position in the request array.
| Status | Meaning |
|---|---|
201 |
batch processed — inspect rejected for per-row failures (incl. (0,0) coords, or an operationId outside the token's ops) |
401 |
missing or bad token (signature fails, or kid not live) |
403 |
valid token lacking the endpoint's scope |
404 |
every referenced operationId is unknown |
422 |
malformed payload (missing required field, bad enum/range, > 1000 rows) |
POST /api/feed/detections¶
Scope detections:write. Body { "detections": [ row, … ] }.
| Field | Type | Req | Notes |
|---|---|---|---|
operationId |
int > 0 | ✔ | must be in the token's ops claim |
kind |
enum | ✔ | detection | ew_emitter |
affiliation |
enum | ✔ | hostile | friendly | neutral | unknown |
lat / lon |
number | ✔ | −90..90 / −180..180; (0,0) rejected per-row |
source |
string | ✔ | 1–64; free-text producer/feed name |
observedAt |
int | ✔ | epoch ms |
sourceKind |
enum | – | ai | sensor | feed | human | manual |
sourceLabel |
string | – | ≤120; human-readable producer |
confidence |
int | – | 0–100 |
sidc |
string | – | APP-6E (ADATP-37) 30-char only — this is an internal boundary; 20-char legacy / pre-E codes are rejected here. A producer holding legacy codes must normalize via the interop gateway first. See glossary SIDC. |
callsign / displayName |
string | – | ≤255 |
heading / speed / alt |
number | – | 0–360° / ≥0 m/s / metres. Speed is stored as m/s system-wide and converted to knots/km-h for display per the track's SIDC — see glossary track_hits. |
freqBand |
string | – | ≤32 |
externalId |
string | – | ≤128; producer's own id |
tleErrorM |
number | – | metres (< 6 = CAT-1) |
tleCategory |
enum | – | cat1…cat4 (derived from tleErrorM if omitted) |
imageUrl |
url | – | http/https; imagery by-reference, never inline bytes |
imageCaption |
string | – | |
capturedAtMs |
int | – | epoch ms |
curl -sS -X POST https://web.example/api/feed/detections \
-H "Authorization: Bearer $WAYPOINT_INGEST_JWT" \
-H "Content-Type: application/json" \
-d '{
"detections": [
{ "operationId": 42, "kind": "detection", "affiliation": "unknown",
"lat": 50.812, "lon": -1.087, "source": "nightingale",
"sourceKind": "sensor", "sourceLabel": "Nightingale radar",
"confidence": 80, "sidc": "100130000000000000000000000000", "observedAt": 1700000000000 }
]
}'
# → 201 { "accepted": 1, "rejected": [] }
POST /api/feed/targets¶
Scope targets:write. Body { "targets": [ row, … ] }. Ingested targets enter the board in
the detected state; source is forced to ingest server-side (not a caller field).
| Field | Type | Req | Notes |
|---|---|---|---|
operationId |
int > 0 | ✔ | must be in the token's ops claim |
name |
string | ✔ | 1–255 |
description |
string | – | ≤4000 |
priority |
enum | – | low | medium | high | urgent |
affiliation |
enum | – | friendly | hostile | neutral | unknown |
unitType |
enum | – | APP-6 unit-type key (see UNIT_TYPES) |
lat / lon |
number | – | −90..90 / −180..180 |
linkedPrincipalId |
string | – | ≤64 |
linkedDetectionId |
int > 0 | – | provenance link to a detection |
tleErrorM |
number | – | metres (< 6 = CAT-1) |
tleCategory |
enum | – | cat1…cat4 (derived from tleErrorM if omitted) |
imageUrl |
url | – | http/https; imagery by-reference |
imageCaption |
string | – | |
capturedAtMs |
int | – | epoch ms |
metadata |
jsonb | – | free-form (e.g. SIGINT intel) |
confidence |
int | – | 0–100 |
curl -sS -X POST https://web.example/api/feed/targets \
-H "Authorization: Bearer $WAYPOINT_INGEST_JWT" \
-H "Content-Type: application/json" \
-d '{
"targets": [
{ "operationId": 42, "name": "SAM site ALPHA", "affiliation": "hostile",
"lat": 50.812, "lon": -1.087, "tleErrorM": 4.2, "tleCategory": "cat1",
"imageUrl": "https://imagery.example/geoint/alpha-001.jpg",
"imageCaption": "GEOINT pass 2026-06-14", "confidence": 90 }
]
}'
# → 201 { "accepted": 1, "createdIds": [123], "rejected": [] }
Tracks (track_hits) have no HTTP endpoint
Ambient position feeds (AIS/ADS-B/radar) are not part of this API — NiFi writes them
directly to Postgres under a least-privilege role. There is nothing to curl; the
"interface" is the track_hits column set. See
Provision feed ingestion.
Relationship to identity¶
These are machine credentials and a separate trust root from operator / device / server identity:
| Operator / device / server | Machine ingest | |
|---|---|---|
| Credential | Directory-signed Ed25519 IdentityToken / ServerToken |
HS256 JWT |
| Verified by | verifyAuthEnvelope (envelope gates) |
signature + DB live-lookup |
| Trust root | Directory Ed25519 signing key | deployment secret WAYPOINT_API_JWT_SECRET |
| Revocation | Directory-signed RevocationList |
api_credentials.revoked_at |
| Classification-gated | Yes | No |
Rides AuthEnvelope |
Yes | No |
An ingest credential authorizes a machine to write feed data; it is not a principal identity and does not carry a clearance.
Where to read the code¶
| Concern | Location |
|---|---|
Credential registry (table, findByKidLive, revoke) |
web/app/domains/api_credentials/ |
| Bearer verify + scope enforcement | web/app/middleware/api_auth_middleware.ts |
Ingest controllers + payload shape + per-row ops check |
web/app/domains/api/ |