Appearance
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 onlystatus,versionandtimestamp; 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:
| Code | When |
|---|---|
400 | a bound that is not a number, from after to, a window wider than a day, or a tz_offset_minutes outside −840…840 |
503 | the database holding the archive index cannot be read |
500 | the archive on disk cannot be read |
Where the answer comes from depends on the deployment:
| Deployment | Closed segments | The open one |
|---|---|---|
| Single node, no database | a walk of the recordings directory | added when the camera is recording |
| Single node with a database | the recordings table | added when the camera is recording |
| Cluster, camera recorded here | recordings — every node's rows | added |
| Cluster, camera recorded elsewhere | 307 to that node | answered 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.mp4Known 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 URL502— the camera answered with an error or with something that is not an image504— 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
- Connect: Establish a WebSocket connection.
- Init Segment: The server immediately sends the fMP4 Initialization Segment (ftyp + moov) as a binary message.
- 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,zoommust be in range[-1.0, 1.0]speedis 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:
- The node must know the camera — from the config file, the database or the cluster.
- PTZ must be enabled for this camera (
ptz: true). - Camera RTSP URL must include username/password (used for ONVIF auth).
Typical error statuses:
404camera not found403PTZ disabled for camera422invalid movement values502camera/ONVIF request failed