Skip to content

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:write to push detections, targets:write to push targets — and an ops claim covering the operation(s) you write to. Mint it first: Issue an API credential.
  • The operationId of each operation you'll write into (an integer; it must be in the credential's ops claim, 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 (scope detections:write) or POST /api/feed/targets (scope targets: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

  1. Get a token and set it as a bearer header (Issue an API credential).
  2. Build the batch — an array of rows (≤1000) under detections (or targets), each with its operationId.
  3. POST it to /api/feed/detections (or /targets) with Authorization: Bearer <jwt> and Content-Type: application/json.
  4. Read the response. On 201, inspect { accepted, rejected } — re-queue or log the rejected rows (each carries its original index + reason). On 4xx, see the codes above.
  5. Handle auth failures — a 401 means rotate/re-mint the token; a 403 means the token is missing the detections:write / targets:write scope.

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 matchsidc + affiliation; signature match strengthconfidence; producersource/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 (cat1cat4, 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/detections with a valid token returns 201 and an { accepted, rejected } body; accepted detections appear in the COP (and the Observations triage inbox) for the operationId.
  • A POST /api/feed/targets returns the same { accepted, rejected }; accepted targets appear on the target board in the detected state, carrying their TLE accuracy and — if the image origin is allowlisted — the GEOINT image.
  • A row at (0,0), or one whose operationId is outside the token's ops claim, comes back in rejected (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 (or targets:write). Re-mint with the scope.
  • 404 on the whole batch. Every row's operationId is unknown (or not in the token's ops). Check the operation ids and the credential's ops claim.
  • 422. The batch is malformed — a missing required field (detections need kind/affiliation/ observedAt; targets need name), 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 operationId is the operation the operator is actually viewing, and that observedAt is recent enough to be in the live window.
  • Target lands but the GEOINT image is blank/broken. Either imageUrl isn't HTTP(S)-reachable (e.g. it's still an FTP path), or the image's origin isn't in the CSP img-src allowlist (WAYPOINT_IMAGERY_IMG_SRC) — see Stand up the web client.

See also


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.