◆ ForgeVis
Skip to content

Management API ​

ForgeVis provides a RESTful API for managing cameras and a WebSocket API for low-latency streaming.

Base URL ​

Default: http://localhost:9997

Endpoints ​

Health Check ​

Two probes are exposed:

  • GET /api/health/live — liveness probe. Returns 200 as long as the process can answer HTTP. Use as the systemd-watchdog or container liveness probe.
  • GET /api/health/ready — readiness probe. Verifies dependencies: storage backend, cluster store, recordings directory writable, license not expired. Returns 200 when all checks pass, 503 otherwise. Without credentials the answer carries only status, version and timestamp; the per-check details (checks) go to a caller allowed into the management API. Use as the load-balancer probe — it needs no credentials.

Example ready response on a healthy node:

json
{
  "status": "ready",
  "version": "0.2.8",
  "timestamp": "2026-05-29T10:15:00+00:00",
  "checks": {
    "storage": { "configured": true, "reachable": true },
    "cluster_store": { "configured": false },
    "recordings_dir": { "path": "/var/lib/forgevis/recordings", "writable": true },
    "license": { "valid": true }
  }
}

Node Metrics ​

GET /api/metrics

Lightweight runtime state, and the name the node calls itself.

json
{
  "online": true,
  "node_name": "recorder-north",
  "cpu_load": 12,
  "mem_used_gb": 6,
  "mem_total_gb": 32,
  "storage_free_gb": 812,
  "storage_used_gb": 1100,
  "timestamp": "2026-08-24T09:15:00+00:00"
}

node_name comes from the top-level nodeName setting and is the name a management platform should register the node under — asked of the node rather than typed into a form. Cameras are handed to a recorder by that name, matched against cameras.node_name in the database, and a mismatch of one character produces no error anywhere: the node reads an empty camera list and goes on answering health checks.

TIP

Leave nodeName empty and the node takes the system hostname. There is no built-in default: a shared one would have two nodes report the same name and both record the same cameras. With a database configured, a node that can resolve no name refuses to start.

For Prometheus scraping there is a separate listener — see monitoring.

Segment Listing ​

GET /api/archive/{camera_id}/segments?from=<epoch>&to=<epoch>

A camera's archive over a window — the closed segments and the one being written right now — together with the days the camera has any recordings for. One request answers both questions a timeline asks: what fills the chosen day, and which days are worth offering. The recording in progress does not have to be asked about separately.

from and to are epoch seconds. With neither given the window is today; with only from it is the day from it, with only to the day up to it. The window is at most a day (25 hours, so a local day with a daylight-saving shift fits): a timeline draws one day, and the days worth offering come in days of the same answer.

A segment is part of the answer when it overlaps the window, including one that began before from and runs into it — otherwise the first segment of a day would be missing from that day's listing.

days does not depend on the window: it is every day of the camera's archive, YYYY-MM-DD. With tz_offset_minutes — the viewer's minutes ahead of UTC, 180 for UTC+3 — the days are dated on the viewer's clock, otherwise on the node's: a recording late in the node's evening is the next morning for a viewer a few hours east. The calendar is built from the same answer, without walking the archive.

json
{
  "archive": [
    {
      "timestamp": "2026-08-21T14:54:43+00:00",
      "sequence": 0,
      "filename": "2026/08/21/stream-camera_001_2026-08-21_14-54-43.mp4",
      "size": 921600000,
      "mtime": 1755780883,
      "is_open": false
    },
    {
      "timestamp": "2026-08-21T15:09:43+00:00",
      "sequence": 0,
      "filename": "2026/08/21/stream-camera_001_2026-08-21_15-09-43.mp4",
      "size": 86114304,
      "mtime": 1755781783,
      "is_open": true
    }
  ],
  "days": ["2026-08-19", "2026-08-20", "2026-08-21"]
}

Exactly one segment carries is_open — the one still being written. Said plainly rather than left to be inferred: a viewer that reaches the end of it has not reached the end of the recording, only the end of what existed when it asked, and telling the two apart by comparing a duration against the clock guesses wrong the moment the listing is a few minutes old. On every other segment is_open is false and size is final.

A camera the node knows nothing about is answered with an empty listing — 200 with empty archive and days, not an error.

Errors are answered as errors, never as an empty archive:

CodeWhen
400a bound that is not a number, from after to, a window wider than a day, or a tz_offset_minutes outside −840…840
503the database holding the archive index cannot be read
500the archive on disk cannot be read

Where the answer comes from depends on the deployment:

DeploymentClosed segmentsThe open one
Single node, no databasea walk of the recordings directoryadded when the camera is recording
Single node with a databasethe recordings tableadded when the camera is recording
Cluster, camera recorded hererecordings — every node's rowsadded
Cluster, camera recorded elsewhere307 to that nodeanswered there

Clusters

Any node of the cluster may be asked. When another node records the camera, the answer is 307 Temporary Redirect naming that node, with from and to kept. The caller repeats the request itself, with its own credentials — nodes hold no credentials for one another.

Recording in Progress ​

GET /api/archive/{camera_id}/current

The file this node is writing for a camera right now, and the moment it was opened.

A segment is recorded in the database when it closes, so the newest stretch of every camera — up to a whole segment length — is missing from any listing built from that database. This is the endpoint that fills it in, and the node holding the file is the only thing that can answer it.

json
{
  "camera_id": "camera_001",
  "filename": "2026/08/21/stream-camera_001_2026-08-21_15-09-43.mp4",
  "start": "2026-08-21T15:09:43+00:00",
  "size": 86114304
}

size is what had been written when the question was asked, and is larger by the time the answer arrives. There is no duration: the file has no end yet.

Answers 404 when there is nothing to report — the camera is not being recorded, the stream has gone silent and the last file has been closed, or record.format is mp4. A progressive recording keeps its header at the end of the file and is written when the file closes, so an open one cannot be played and offering it would put an unplayable stretch on a timeline.

In a cluster, ask any node: one that does not record the camera answers 307 naming the one that does, the same as the segment listing above.

Play it like any other recording, through the playback server — it serves a file that is still being written.

Archive Jobs (Export & Time-Lapse) ​

Both archive export (segment concatenation, stream-copy) and time-lapse generation (sped-up re-encode) run as asynchronous background jobs with a unified status/download API.

Submit Export ​

POST /api/archive/{camera_id}/export?from=<epoch>&to=<epoch>

Stitches segments overlapping the requested time range into a single MP4 via ffmpeg -c copy (sub-second trim accuracy, no re-encode).

Submit Time-Lapse ​

POST /api/archive/{camera_id}/timelapse?from=<epoch>&to=<epoch>&speed=<N>

Produces a sped-up MP4 from the same range. speed is an integer in [2, 120]; output duration = input duration / speed. Output is x264 (veryfast / CRF 23), downscaled to ≤1280px wide, 30 fps, audio stripped.

Submit Response ​

Both submits return 202 Accepted:

json
{
  "job_id": "1a2b3c...",
  "status_url": "/api/archive/jobs/1a2b3c...",
  "download_url": "/api/archive/jobs/1a2b3c.../download"
}

Common limits: max window 4 hours (413 Payload Too Large otherwise); ffmpeg required on the node (503 Service Unavailable if absent).

Concurrency: 4 parallel exports and 2 parallel time-lapses per node; excess submits queue with status pending until a worker frees up.

Status ​

GET /api/archive/jobs/{job_id}

json
{
  "job_id": "1a2b3c...",
  "camera_id": "camera_001",
  "from": 1780054469,
  "to": 1780054589,
  "created_at": 1780100000,
  "completed_at": 1780100012,
  "kind": "export",
  "state": "done",
  "size_bytes": 18374212
}

For time-lapse jobs kind is timelapse and the speed is included. Possible state values: pending, running, done (with size_bytes), failed (with reason).

Download ​

GET /api/archive/jobs/{job_id}/download

Streams the result MP4. Inline by default; pass ?download=1 to force attachment. Returns 202 if the job is still processing, 410 Gone if the result has expired (results are retained for 1 hour after completion).

Example ​

bash
JOB=$(curl -sX POST 'http://127.0.0.1:9997/api/archive/camera_001/export?from=1780054469&to=1780054589' | jq -r .job_id)
while [ "$(curl -s "http://127.0.0.1:9997/api/archive/jobs/$JOB" | jq -r .state)" != "done" ]; do
  sleep 1
done
curl "http://127.0.0.1:9997/api/archive/jobs/$JOB/download?download=1" -o clip.mp4

Known limitation: this endpoint reads files local to the receiving node only. If the camera lived on multiple nodes during the window, calling one node returns only its portion of the archive. For seamless export across migrations use the management service (platform/backend, coming soon) — see Multi-node archive.

Delegated assembly (/assemble) ​

POST /api/archive/{camera_id}/assemble

A service endpoint the management service calls when it picks this node as the "assembler" for a multi-node export. Takes a list of already- finalised sub-exports from other nodes, downloads them in parallel, and concats them into a single MP4. If the body specifies kind: "timelapse" with speed, an extra ffmpeg pass with setpts=PTS/N runs after concat.

Body:

json
{
  "kind": "export",
  "parts": [
    {"peer_addr": "10.0.0.1:9997", "peer_job_id": "abc...", "ordinal": 0},
    {"peer_addr": "10.0.0.2:9997", "peer_job_id": "def...", "ordinal": 1}
  ]
}

Response is the same 202 Accepted shape with a job_id — the result is then observed through the usual /api/archive/jobs/{id} and /download endpoints. This endpoint is intended for trusted-network service-to-service traffic and isn't useful directly from a UI.

Camera Management ​

Start Camera ​

POST /cameras/start

Starts a camera by ID.

Body:

json
{
  "id": "camera_001"
}

Response:

json
{
  "status": "ok",
  "started": 1
}

Stop Camera ​

POST /cameras/stop

Stops a camera by ID.

Body:

json
{
  "id": "camera_001"
}

Response:

json
{
  "status": "ok",
  "stopped": 1
}

Camera Snapshot ​

GET /api/cameras/{id}/snapshot

The camera's still image, as the camera made it — usually JPEG. The node fetches it from the camera's snapshot URL, or snapuri when the camera comes from the database, answering a Digest or Basic login request with the credentials of that URL or of source.

One still serves every request for the camera for 15 seconds, and the response says so in Cache-Control. Requests that arrive while the node is fetching wait for that fetch rather than start their own, so a camera wall asks each camera once.

Error statuses:

  • 404 — no such camera, or the camera has no snapshot URL
  • 502 — the camera answered with an error or with something that is not an image
  • 504 — the camera did not answer in 10 seconds

Low-Latency Streaming (MSE) ​

ForgeVis supports low-latency streaming (<1s) directly to web browsers using Media Source Extensions (MSE) over WebSocket.

WebSocket Endpoint ​

URL: ws://localhost:9997/websocket/{camera_id}

Protocol ​

  1. Connect: Establish a WebSocket connection.
  2. Init Segment: The server immediately sends the fMP4 Initialization Segment (ftyp + moov) as a binary message.
  3. Media Fragments: The server sends fMP4 Media Fragments (moof + mdat) as binary messages in real-time.

Client Implementation (JavaScript) ​

javascript
const video = document.querySelector('video');
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);

mediaSource.addEventListener('sourceopen', () => {
    // Use correct codec string (e.g., avc1.4d401f for H.264 Main Profile)
    const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.4d401f"');
    
    const ws = new WebSocket('ws://localhost:9997/websocket/camera_001');
    ws.binaryType = 'arraybuffer';
    
    ws.onmessage = (event) => {
        if (!sourceBuffer.updating) {
            sourceBuffer.appendBuffer(event.data);
        } else {
            // Handle backpressure/queueing in production
        }
    };
});

PTZ Control API ​

ForgeVis provides ONVIF PTZ control endpoints in Management API.

Move Camera ​

POST /api/ptz/move

Body:

json
{
  "camera_id": "camera_001",
  "pan": 0.4,
  "tilt": -0.2,
  "zoom": 0.0,
  "speed": 0.5
}

Rules:

  • pan, tilt, zoom must be in range [-1.0, 1.0]
  • speed is optional and clamped to [0.0, 1.0]

Success response:

json
{
  "status": "ok",
  "message": "ptz move completed"
}

Stop Camera Movement ​

POST /api/ptz/stop

Body:

json
{
  "camera_id": "camera_001"
}

Success response:

json
{
  "status": "ok",
  "message": "ptz stop completed"
}

PTZ Prerequisites ​

For PTZ endpoints to work:

  1. The node must know the camera — from the config file, the database or the cluster.
  2. PTZ must be enabled for this camera (ptz: true).
  3. Camera RTSP URL must include username/password (used for ONVIF auth).

Typical error statuses:

  • 404 camera not found
  • 403 PTZ disabled for camera
  • 422 invalid movement values
  • 502 camera/ONVIF request failed

Proprietary software.