Base URL: http://<host>:4000/api (or /api relative to wherever the frontend is served, since it's always proxied/same-origin).
Authentication: session cookie required, except GET /api/health and the /api/auth/* endpoints themselves (see below). Sign in via POST /api/auth/login (or POST /api/auth/setup on first run) - the response sets an httpOnly session cookie, sent automatically by the browser on subsequent requests. Sessions expire after 1h (JWT_EXPIRES_IN, see Configuration); an expired/missing session gets 401 { "error": "Unauthorized" }. This also covers the /hls, /recordings, and /snapshots proxies, and the WebSocket connection.
All request bodies are validated with zod; invalid payloads return 400 with { error, details } (details is zod's flatten() output).
Returns { "status": "ok" }. No dependencies checked. Does not require a session.
None of these require an existing session (that would be circular).
Returns { hasUser: boolean, authenticated: boolean }. hasUser: false means no account has been created yet - the frontend shows Setup instead of Login in that case. authenticated reflects whether the request's own session cookie (if any) is currently valid.
Body: { username, password } (username 3-64 chars, password 8+ chars). Creates the first (and only) admin account and signs you in (sets the session cookie). Returns 409 if an account already exists - use /login instead.
Body: { username, password }. Verifies against the stored bcrypt hash and sets the session cookie on success. Returns 401 on any mismatch (deliberately doesn't distinguish "unknown user" from "wrong password").
Clears the session cookie. No body.
List all cameras (password field stripped from the response).
Get one camera by id. 404 if not found.
Live view of MediaMTX's actual state for this camera's path — use this to tell "ONVIF connected" apart from "MediaMTX actually pulling RTSP".
Response:
Create a camera. The row is always created even if ONVIF/MediaMTX provisioning fails below (camera ends up with status: "offline").
Body:
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | |
sourceType |
"onvif" | "rtsp" | "rtmp" | "hls" | "srt" | "mjpeg-http" | "webpage" |
no | default "onvif". For any non-"onvif" value, host/username/password aren't required and rtspMainUri becomes the directly-entered stream/page URL instead - see Features → Camera management. |
host |
string | required for sourceType: "onvif" |
|
port |
number | no | default 80 |
onvifPath |
string | no | default /onvif/device_service |
username |
string | required for sourceType: "onvif" |
|
password |
string | required for sourceType: "onvif" |
|
mainProfileToken / subProfileToken |
string | no | from /onvif/probe |
rtspMainUri / rtspSubUri |
string | no | pre-resolved main/sub stream, from /onvif/probe - or the directly-entered URL, for non-"onvif" source types (required in that case) |
rtspCompatMode |
"vlc-relay" | null |
no | enable the VLC RTSP compatibility relay |
rotation |
0 | 90 | 180 | 270 |
no | default 0. Clockwise rotation applied via an ffmpeg transcode bridge before the stream reaches MediaMTX. |
hasPtz |
boolean | no | default false. Can be true even for a non-"onvif" sourceType, as long as host/username/password are also set - PTZ still goes over ONVIF independent of where the video comes from. |
mainStreamMetadata / subStreamMetadata |
{ width, height, encoding } |
no | display-only, from probe |
recordingMode |
"off" | "continuous" | "motion" |
no | default "off" |
motionRecording |
boolean | no | enables the ONVIF motion/tamper alert listener (independent of recordingMode) |
retentionDays |
number | no | default 7. Enforced daily: MediaMTX deletes recordings older than this via a per-camera recordDeleteAfter setting, and a backend cron job (backend/src/jobs/retentionCleanup.ts) deletes event rows + snapshot files older than this, once a day. |
Returns 201 with the created camera.
Partial update (same shape as POST, all fields optional). If any connection field changes (host, port, onvifPath, username, password, mainProfileToken) and no new rtspMainUri is given, the previously resolved RTSP URI is invalidated and re-resolved via ONVIF on the next provisioning pass. Also restarts/stops the event listener and motion-recording timers as needed based on the new settings.
Forces a full reconnect: re-resolves the RTSP URI via ONVIF and re-registers the MediaMTX path. Returns { ok: boolean, status: "online" | "offline" | "unknown" }.
Same as POST /api/onvif/probe below, but scoped to this already-saved camera: any field omitted from the body (host, port, onvifPath, username, password) falls back to that camera's stored value - in particular, the password, which is never sent back to the client for display. Lets the edit dialog's "Obter URLs de vídeo" button re-probe and re-list every available stream without forcing the user to retype a password that's already saved, as long as they're not deliberately changing credentials.
Body: { "host"?, "port"?, "onvifPath"?, "username"?, "password"? } (all optional). Response/failure shape identical to POST /api/onvif/probe.
Administrative on/off switch, distinct from the connectivity-based status field. Stops the motion listener/detector, motion-recording cooldown, and VLC relay (if any); deletes the MediaMTX path; sets enabled: false on the camera row (config is kept, nothing is deleted). Disabled cameras are skipped on backend boot and by the periodic MediaMTX-path reconciliation loop. Returns the updated camera.
Re-enables a previously disabled camera: sets enabled: true, then re-provisions it (fresh ONVIF lookup + MediaMTX path registration, same as /restart) and resumes motion detection if configured. Returns the updated camera.
Stops the event listener, motion-recording cooldown, and VLC relay (if any); deletes the MediaMTX path (a missing/404 path is treated as already-gone, not an error); deletes the DB row. 204 No Content.
Connects via ONVIF right now and lists available stream profiles, without changing anything persisted.
Success: { "ok": true, "streams": [...] }
Failure: 502 with { "ok": false, "error": "...", "details": "..." } (details is the raw underlying error message, e.g. ECONNRESET).
Runs a WS-Discovery probe on the LAN.
Body: { "timeoutMs"?: number } (default 5000).
Response: array of
{ "hostname": "192.168.1.50", "port": 80, "urn": "urn:uuid:...", "xaddrs": ["http://192.168.1.50/onvif/device_service"] }Active TCP range scan for ONVIF/RTSP cameras that don't announce themselves over WS-Discovery (or when it's blocked/disabled on the network). Unlike every other endpoint, the response is streamed as newline-delimited JSON (application/x-ndjson) instead of a single JSON body at the end, so the frontend can show live progress for ranges that take a while to fully scan.
Body: { "range": "192.168.1.0/24", "username"?: string, "password"?: string } (range also accepts a plain start-end form, see backend/src/lib/ipRange.ts; username/password are optional and only used to also attempt an authenticated ONVIF handshake against any responsive host).
Each streamed line is one JSON object, one of:
{ "type": "start", "totalHosts": 254 }
{ "type": "error", "message": "..." }
{ "type": "done" }(plus per-host progress/result events - see backend/src/onvif/networkScan.ts for the full event shape).
Connects to a camera via ONVIF (without saving anything) and returns every media profile's resolved RTSP stream URI.
Body — either:
{ "onvifUrl": "http://user:pass@192.168.1.50:80/onvif/device_service" }or separate fields: { "host", "port"?, "onvifPath"?, "username", "password" }.
Response:
{
"host": "192.168.1.50", "port": 80, "onvifPath": "/onvif/device_service", "username": "admin",
"streams": [
{ "profileToken": "profile_1", "name": "MainStream", "encoding": "H264", "width": 1920, "height": 1080, "rtspUri": "rtsp://192.168.1.50:554/onvif1" }
]
}Failure: 502 with { error, details }.
Diagnostic-only, not used by the normal add-camera flow. Sends the same unauthenticated ONVIF call (GetSystemDateAndTime) in different SOAP wire formats directly (bypassing the onvif package) to narrow down why a camera resets the connection during the ONVIF handshake. Same body shape as /probe (username/password optional — enables an extra authenticated attempt).
Response: { host, port, onvifPath, results: [{ label, ok, statusCode?, bodyPreview?, error? }, ...] }.
Lists every command available in the ONVIF debug console, with name/args/description, e.g. device.info, device.capabilities, media.profiles, ptz.presets, etc.
Executes one raw ONVIF command against a stored camera's saved credentials (the request never supplies credentials directly).
Body: { "command": "device.info", "args": [] }.
Success: { "ok": true, "result": <command-specific JSON> }. Failure: 502 with { "ok": false, "error": "..." }.
Body: { "direction": "up"|"down"|"left"|"right"|"upLeft"|"upRight"|"downLeft"|"downRight", "speed"?: number (0-1) }. 204 on success, 502 on PTZ failure.
Stops any ongoing continuous move. 204.
Lists saved PTZ presets for the camera.
Body: { "name": string }. Saves the camera's current position as a new preset. 201 with the created preset.
Moves the camera to a saved preset. 204.
All PTZ endpoints return 404 if the camera doesn't exist, and 502 with a generic error message if the ONVIF PTZ call itself fails.
Lists recorded segments for a camera within an optional time range, read live from MediaMTX's Playback API (no separate recordings DB).
Response: array of
{ "start": "2026-07-28T10:00:00Z", "duration": 60, "url": "/recordings/get?path=<cameraId>&start=...&duration=60" }url is relative and always points back at this same backend (proxied to MediaMTX, see Architecture) — fetch the actual video bytes from it directly (e.g. as a <video src>).
All filters optional. from/to default to "since forever" / "now" (ISO 8601). 404 if cameraId is given but doesn't exist.
Response: array of
{ "id": "...", "camera_id": "...", "type": "tns1:VideoSource/MotionAlarm", "occurred_at": "2026-07-28T10:00:00Z", "metadata": {...} | null, "read": false, "snapshotUrl": "/snapshots/<cameraId>/<eventId>.jpg" | null }Single event by id.
Body: { "read": boolean }. Marks an event read/unread.
Deletes an event. 204.
A saved layout: name, column count, and an ordered list of camera ids. Like every other endpoint, this now requires a session too - the kiosk URL (/g/:id) redirects to /login if there isn't one (see Troubleshooting for what this means for sharing it).
List all grids.
Get one grid. This is the endpoint the kiosk view page (/g/:id) calls. 404 if not found.
Body: { "name": string, "columns"?: number (1-8, default 3), "cameraIds": string[] } (order of the array = display order).
Partial update, same shape.
204 on success, 404 if not found.
Runtime-editable configuration, persisted in the database (takes precedence over the env vars in Configuration, which only serve as deploy-time/first-boot defaults). Backs the Configurações page.
Returns the current notification-channel configuration. Never returns raw secrets (webhook URLs, bot tokens, SMTP password) - only whether each channel is configured (*Configured: boolean) plus its non-secret fields (e.g. emailSmtpHost, emailSmtpPort, emailFrom/emailTo) and each channel's *AttachSnapshot toggle.
Partial update. Body: any subset of discordWebhookUrl, discordAttachSnapshot, telegramBotToken, telegramChatId, telegramAttachSnapshot, webhookUrl, webhookAttachSnapshot, emailSmtpHost, emailSmtpPort, emailSmtpUser, emailSmtpPass, emailSmtpSecure, emailFrom, emailTo, emailAttachSnapshot. A field set to null clears/disables that value; a field simply omitted leaves it unchanged (this is how the UI supports "leave the secret blank to keep the existing one"). Returns the updated settings in the same shape as GET.
Sends a real test notification through one channel right now, using its currently saved configuration.
Body: { "channel": "discord" | "telegram" | "webhook" | "email" | "push" }.
Success: { "ok": true }. Failure: 502 with { "ok": false, "error": "..." } (e.g. invalid webhook URL, SMTP auth failure, or no push subscription registered yet).
Returns { "language": "pt-BR" } - the backend's own UI language, used for server-generated text (notification messages, etc.).
Body: { "language": string } (one of the 12 supported language codes). Kept in sync automatically with the frontend's language switcher, so notifications are worded in whatever language the UI is actually shown in, not necessarily the server's OS locale. Returns { "language": "..." }.
Browser/PWA Web Push subscriptions - see Features → Push notifications. Backed by backend/src/lib/webPush.ts and the push_subscriptions table.
Returns { "publicKey": "..." } - the VAPID public key the frontend needs to create a PushSubscription via pushManager.subscribe(). Generated automatically on first use and persisted in the settings table (or pinned via VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY env vars - see Configuration); stable across restarts either way.
Registers (or refreshes, if the same endpoint already exists) a browser/device subscription.
Body: { "endpoint": "https://...", "keys": { "p256dh": "...", "auth": "..." } } (the exact shape of PushSubscription.toJSON()).
Response: 201 { "ok": true }.
Removes a subscription so it stops receiving pushes.
Body: { "endpoint": "https://..." }.
Response: { "ok": true }.
Current CPU/memory/disk usage of the host the backend is running on - backs the always-visible status bar (and the standalone Dashboard page, if its route is re-enabled - see Features → Dashboard). No configuration needed; computed from Node's built-in os/fs modules (see Architecture).
Response:
{
"cpu": { "usagePercent": 12.3, "cores": 10, "loadAvg": [1.59, 0.84, 0.69] },
"memory": { "totalBytes": 8381853696, "freeBytes": 6839517184, "usedBytes": 1542336512, "usagePercent": 18.4 },
"disks": [
{ "label": "Recordings", "path": "/recordings", "totalBytes": 43826053120, "freeBytes": 14917840896, "usedBytes": 28908212224, "usagePercent": 66 },
{ "label": "Application data", "path": "/data", "totalBytes": 43826053120, "freeBytes": 14917840896, "usedBytes": 28908212224, "usagePercent": 66 }
],
"uptimeSeconds": 967624.17
}disks always reports both RECORDINGS_DIR and DATA_DIR (see Configuration) - if they resolve to the same filesystem, both entries simply show identical numbers.
Administrative actions backing the Maintenance page - see Features → Maintenance. All require an active session, same as everything else in the app.
Tails recent backend log entries from an in-memory ring buffer. All query params optional: cameraId scopes to one camera's logs, afterSeq returns only entries newer than a given sequence number (for polling a live tail without re-fetching everything), limit caps the count.
Response: { "entries": [{ "seq": number, "level": string, "time": string, "cameraId"?: string, "msg": string, ... }], "lastSeq": number }.
Body: { "currentPassword": string, "newPassword": string (8+ chars) }. Requires the current password to match (defense in depth beyond the session cookie itself). 400 with { error } if it doesn't match or the payload is invalid. Success: { "ok": true }.
No body. Responds 202 { "ok": true } before exiting the process 300ms later - relies entirely on the container's restart: unless-stopped policy (or an equivalent process manager) to bring it back up; there's no in-process "reload".
Body: { "password": string } - the current admin password, required as confirmation given how destructive this is (the frontend additionally requires typing a confirmation phrase before even calling this). 400 with { error } if the password doesn't match.
Wipes every camera (tearing down MediaMTX paths, motion listeners, VLC relays, and ffmpeg/Chromium bridges first), all recording files on disk, all events, all grids, all notification settings, and the admin account itself - the next page load shows Setup again, as if freshly installed. Responds { "ok": true } then exits the process 500ms later (same restart-policy assumption as above).
Body: { "cameraId"?: string }. Deletes recorded video files from disk for one camera, or for every camera if cameraId is omitted. Only touches the filesystem - MediaMTX manages recordings natively, so there's no DB table to also clean up. Success: { "ok": true }.
| Route | Purpose |
|---|---|
GET /hls/:cameraId/index.m3u8 (+ segments) |
Reverse-proxied to MediaMTX's HLS server. Rewrites MediaMTX's cookie-check redirect Location header to keep the /hls prefix. |
GET /recordings/get?path=&start=&duration= |
Reverse-proxied to MediaMTX's Playback server (used by the url field returned from /api/recordings/:cameraId). |
GET /snapshots/:cameraId/:eventId.jpg |
Static files — event snapshots captured by the ONVIF event listener. |
GET /web/* |
The built frontend SPA (production only — only exists after npm run build; falls back to index.html for client-side routes). |
The server broadcasts to all connected clients (no rooms/targeting):
| Event | Payload | When |
|---|---|---|
camera:event |
{ cameraId, type, occurredAt, ...extra } |
An ONVIF motion/tamper/etc. event was received for a camera with alerts enabled. |
camera:status |
{ cameraId, status } |
Emitted by emitCameraStatus() (available for future use — not currently called from anywhere in the codebase, so camera status updates are only reflected via re-fetching /api/cameras, not real-time). |
The frontend only listens for camera:event today (EventSocketListener).
{ "configured": true, // path registered in MediaMTX "ready": true, // RTSP source actually connected "sourceType": "rtspSession", "readerCount": 1, "bytesReceived": 12345678, "hlsUrl": "/hls/<id>/index.m3u8", "relayUrl": null // VLC relay RTSP URL, only if rtspCompatMode === "vlc-relay" }