Push detections & targets (/api/feed/*)¶
What this is: how an external producer sends operational observations — detections and
targets — into a deployment over the HTTP /api/feed/* ingest API.
When you'd do it: wiring up a sensor/AI/feed that asserts operationally meaningful contacts (an affiliation, a confidence, a classification) — e.g. Nightingale radar. For ambient position feeds (AIS/ADS-B) you want the other path → Provision feed ingestion. Not sure which? → Getting data into the COP.
Before you start¶
- An API credential (a signed JWT) with the right scope —
detections:writeto push detections,targets:writeto push targets — and anopsclaim covering the operation(s) you write to. Mint it first: Issue an API credential. - The
operationIdof each operation you'll write into (an integer; it must be in the credential'sopsclaim, or the rows are rejected). - The producer can make an outbound HTTPS POST to the web tier.
The contract in brief¶
Full reference: Machine Ingest API. The essentials:
POST /api/feed/detections(scopedetections:write) orPOST /api/feed/targets(scopetargets:write).- Header:
Authorization: Bearer <jwt>. No cookie, no CSRF token. - Body is a batch (1–1000 rows):
{ "detections": [ … ] }(or{ "targets": [ … ] }). - Response is per-row partial success:
{ "accepted": …, "rejected": [ … ] }. Your good rows land even if a few are rejected. - Status codes: 422 malformed batch · 404 every row aimed at an unknown operation · 403 token lacks the scope · 401 bad/expired/revoked token.
A detection row (the fields the API accepts)¶
Required: operationId, kind (detection | ew_emitter), affiliation
(hostile | friendly | neutral | unknown), lat, lon, source (free-text producer
name, 1–64 chars), observedAt (epoch ms).
Common optional: confidence (integer 0–100), sourceKind
(ai | sensor | feed | human | manual), sourceLabel (human-readable producer name),
sidc (APP-6E 30-char only; legacy codes must be normalized via the interop gateway first),
callsign, displayName, heading, speed (m/s), alt, freqBand, externalId,
and the CAT-1 / imagery fields (tleErrorM, tleCategory, imageUrl, imageCaption,
capturedAtMs). (0,0) is rejected per-row (the "no fix" sentinel).
There is no free-form metadata field on the ingest API today
A detection row maps producer richness onto the named fields above — classification →
sidc + affiliation, match strength → confidence, emitter frequency → freqBand,
producer → source + sourceKind + sourceLabel. The table has a raw jsonb column, but
it is not exposed in the ingest schema, so anything that doesn't fit a named field is not
carried. Exposing raw is a possible follow-up if a producer needs to round-trip its native
payload.
Steps¶
- Get a token and set it as a bearer header (Issue an API credential).
- Build the batch — an array of rows (≤1000) under
detections(ortargets), each with itsoperationId. - POST it to
/api/feed/detections(or/targets) withAuthorization: Bearer <jwt>andContent-Type: application/json. - Read the response. On
201, inspect{ accepted, rejected }— re-queue or log therejectedrows (each carries its originalindex+reason). On4xx, see the codes above. - Handle auth failures — a
401means rotate/re-mint the token; a403means the token is missing thedetections:write/targets:writescope.
Worked example — Nightingale radar → detections¶
Nightingale streams classified contacts (ContactResult: position + type match + signature +
confidence). Target flow:
GetWebSocketTLS (Nightingale feed over mutual-auth TLS)
→ ParseNightingaleToDetection (ContactResult protobuf → detection JSON)
→ InvokeHTTP POST /api/feed/detections (Authorization: Bearer <jwt>)
A single-row batch the parser would emit:
{
"detections": [
{
"operationId": 42,
"kind": "detection",
"affiliation": "unknown",
"lat": 50.812,
"lon": -1.087,
"confidence": 80,
"sidc": "100130000000000000000000000000",
"source": "nightingale",
"sourceKind": "sensor",
"sourceLabel": "Nightingale radar",
"freqBand": "X-band",
"observedAt": 1700000000000
}
]
}
The contact's type match → sidc + affiliation; signature match strength →
confidence; producer → source/sourceKind/sourceLabel.
NiFi-side change outstanding
Today NiFi ships ParseNightingaleToTrackHit, which routes Nightingale to track_hits
(a flat radar track with a fixed SIDC) via PutDatabaseRecord — the operational richness is
discarded and NiFi has no /api/feed path at all. The detections flow above needs two
NiFi-side pieces that do not exist yet: a ParseNightingaleToDetection parser and an
InvokeHTTP POST to /api/feed/detections (replacing PutDatabaseRecord). The web endpoint
is already built. Until that lands, Nightingale arrives as a track.
Worked example — CAT-1 targets from a GeoJSON drop + GEOINT imagery¶
High-accuracy targeting: a partner system drops a GeoJSON FeatureCollection of targets on an
FTP server, each feature referencing a GEOINT image by URL. (CAT-1 = target-location error
under 6 m.) These are targets, so they use POST /api/feed/targets (scope targets:write).
Target flow:
GetFTP / FetchFTP (poll the FTP drop for *.geojson target files)
→ ParseGeoJSONToTarget (FeatureCollection → target rows)
→ InvokeHTTP POST /api/feed/targets (Authorization: Bearer <jwt>)
A target row (the fields /api/feed/targets accepts)¶
Required: operationId, name. Optional: description, priority, affiliation, unitType,
lat, lon, confidence (0–100), linkedDetectionId, and the CAT-1 / imagery fields —
tleErrorM (metres), tleCategory (cat1…cat4, derived from tleErrorM if omitted),
imageUrl, imageCaption, capturedAtMs, and a free metadata jsonb (targets do carry this).
source is forced to ingest server-side — callers don't set it. Ingested targets enter the
board in the initial detected state with an audit transition row.
One GeoJSON feature → one target:
// Feature on the FTP drop — GeoJSON coordinates are [lon, lat]
{ "type": "Feature",
"geometry": { "type": "Point", "coordinates": [-1.087, 50.812] },
"properties": {
"name": "SAM site ALPHA", "affiliation": "hostile",
"tle_error_m": 4.2,
"image_url": "https://imagery.example/geoint/alpha-001.jpg",
"image_caption": "GEOINT pass 2026-06-14", "captured_at_ms": 1700000000000 } }
→ the parser POSTs:
{
"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",
"capturedAtMs": 1700000000000,
"confidence": 90
}
]
}
Imagery is by-reference and CSP-gated — and an FTP path is not a browser URL
imageUrl is fetched by the operator's browser, so it must be an HTTP(S) URL the
browser can reach — an FTP path will not render. If the image arrives over FTP, NiFi must
republish it to an HTTP image store (S3/MinIO/CDN) and set imageUrl to that. The image's
origin must also be in the deployment's CSP img-src allowlist (WAYPOINT_IMAGERY_IMG_SRC) or
the COP silently blocks the <img> — see Stand up the web client. HTTPS
origins are covered by the blanket https: source; a self-hosted store on http: or a
non-standard port needs an explicit allowlist entry.
Coordinate order + NiFi-side change outstanding
GeoJSON coordinates are [lon, lat] — the parser swaps them into the API's lat/lon.
tleCategory may be omitted; the server derives it from tleErrorM (< 6 m → cat1). As with
Nightingale, the ParseGeoJSONToTarget parser + InvokeHTTP POST to /api/feed/targets is
the target NiFi flow and does not exist yet — today ConvertGeoJSONToTrackHit maps
GeoJSON only to ambient track_hits (the tracks path), not to targets.
How to know it worked¶
- A
POST /api/feed/detectionswith a valid token returns 201 and an{ accepted, rejected }body; accepted detections appear in the COP (and the Observations triage inbox) for theoperationId. - A
POST /api/feed/targetsreturns the same{ accepted, rejected }; accepted targets appear on the target board in thedetectedstate, carrying their TLE accuracy and — if the image origin is allowlisted — the GEOINT image. - A row at
(0,0), or one whoseoperationIdis outside the token'sopsclaim, comes back inrejected(the rest still land).
If something goes wrong¶
- 401 on every request. Token missing, malformed, expired, or revoked — re-mint (Issue an API credential).
- 403. The token lacks
detections:write(ortargets:write). Re-mint with the scope. - 404 on the whole batch. Every row's
operationIdis unknown (or not in the token'sops). Check the operation ids and the credential'sopsclaim. - 422. The batch is malformed — a missing required field (detections need
kind/affiliation/observedAt; targets needname), a bad enum, an out-of-range coord, or >1000 rows. The response names the offending field. - Rows accepted but nothing on the COP. Confirm the
operationIdis the operation the operator is actually viewing, and thatobservedAtis recent enough to be in the live window. - Target lands but the GEOINT image is blank/broken. Either
imageUrlisn't HTTP(S)-reachable (e.g. it's still an FTP path), or the image's origin isn't in the CSPimg-srcallowlist (WAYPOINT_IMAGERY_IMG_SRC) — see Stand up the web client.
See also¶
- Getting data into the COP — tracks vs detections, which path to use
- Issue an API credential — mint/revoke the JWT this runbook needs
- Machine Ingest API — the full
/api/feed/*contract - Provision feed ingestion — the tracks path (AIS/ADS-B →
track_hits) - Stand up the web client —
WAYPOINT_IMAGERY_IMG_SRC(CSPimg-srcallowlist for GEOINT imagery) - The
bedrock-nifi-processorsrepo — the Nightingale / GeoJSON sources + (target)ParseNightingaleToDetection/ParseGeoJSONToTarget
Verified against web@feature/roadmap — /api/feed/detections + /api/feed/targets (JWT,
scopes detections:write/targets:write, per-row operationId checked against the ops claim,
{accepted, rejected} partial success), the detection ingest schema, and the target ingest schema
(source forced to ingest; CAT-1 tleErrorM/tleCategory/imageUrl/imageCaption/
capturedAtMs/metadata/confidence). The ParseNightingaleToDetection and
ParseGeoJSONToTarget + InvokeHTTP NiFi flows are the target; today ParseNightingaleToTrackHit
and ConvertGeoJSONToTrackHit route to track_hits.