This document describes the REST API endpoints provided by LightNVR.
LightNVR provides a RESTful API that allows you to interact with the system programmatically. The API is accessible via HTTP and returns JSON responses using the cJSON library. The API is served by the libuv + llhttp web server.
If authentication is enabled in the configuration file, endpoints that require
authentication accept one of three mechanisms. Authentication and role-based
access checks are enforced per-endpoint. Where authentication is required, all
three mechanisms resolve to the same user_t, so pick whichever fits the
caller.
Obtain a session by calling the login endpoint, then include the session cookie on subsequent requests.
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
-d '{"username":"admin","password":"yourpassword"}' \
http://your-lightnvr-ip:8080/api/auth/login
curl -b cookies.txt http://your-lightnvr-ip:8080/api/streamsEvery user has an api_key field. Pass it via either header:
# Preferred
curl -H "X-API-Key: <your-api-key>" http://your-lightnvr-ip:8080/api/streams
# Also accepted
curl -H "Authorization: Bearer <your-api-key>" http://your-lightnvr-ip:8080/api/streamsFor automation (Home Assistant, NodeRED, cron jobs, etc.), create a dedicated
user with the USER_ROLE_API role and use that user's api_key. Keep admin
keys out of automation configs. New integrations should use the expiring,
action- and camera-scoped tokens described below. The single legacy key remains
available during the endpoint-enforcement migration.
Also supported as a fallback for tools that only understand basic auth.
Role-based access is enforced per-endpoint. The table below is a high-level summary of the intended access model:
| Role | Can read | Can write | Can administer |
|---|---|---|---|
ADMIN |
yes | yes | yes |
USER |
yes | yes | no |
API |
yes | yes | no |
VIEWER |
yes | typically no | no |
Write authorization is endpoint-specific. Stream creation, updates (including
privacy mode), and deletion reject VIEWER with 403, as does
POST /api/motion/trigger.
New installations and upgrades include the Fleet 02 action catalog, reusable
roles, and camera-selector or shared-collection grants. Existing users remain in legacy authorization
mode until an administrator creates and previews equivalent grants, so upgrading
does not silently remove access. Users switched to policy mode are default-deny:
an action is allowed only when an enabled role grant contains the action and its
all-fleet, shared-collection, or camera-selector scope matches.
Existing handlers are being migrated to the central evaluator incrementally. The
current coverage and intended action for every route family are tracked in
docs/internal/AUTHORIZATION_ENDPOINT_INVENTORY.md.
GET /api/authorization/actions
Administrator-only. Returns the stable action key, category, description, whether a camera resource is required, and whether the action is destructive.
Each entry also reports:
enforced— whether any request handler currently routes through the centralized evaluator for this action. Actions are grantable ahead of their enforcement work, so clients must label the unenforced ones rather than imply a boundary that is not applied yet. Seedocs/internal/AUTHORIZATION_ENDPOINT_INVENTORY.mdfor the coverage map.mask_bit— the bit position this action occupies in a persisted API tokenaction_mask. The position is frozen once an action ships; the daemon refuses to start if the stored layout inauthz_actionsdisagrees with the binary.
A policy manager may only author roles, grants, and tokens whose actions are a
subset of the authority it holds itself. Requests that would widen the
requester's own authority are rejected with 403 and name the offending
actions, so users.manage cannot be used as a path to system.admin.
POST /api/authorization/simulate
Administrator-only and side-effect free. Camera-scoped actions require a stable camera UUID:
{
"user_id": 7,
"action": "recordings.export",
"camera_uuid": "0192a7f0-4f43-4a1d-9e1c-d6947677f145"
}The response reports allowed, the compatibility role or matching policy grant,
the evaluated policy version, and a concise explanation. Global actions such as
users.manage omit camera_uuid.
GET /api/authorization/roles
POST /api/authorization/roles
PUT /api/authorization/roles/{role_uuid}
DELETE /api/authorization/roles/{role_uuid}
Administrator-only. The list response includes the current policy_version and
each role's action keys. Built-in roles are readable but immutable. Create,
update, and delete requests must include the last observed version as
expected_policy_version; stale writes return 409 so concurrent policy edits
cannot silently overwrite one another.
Create and update bodies use a complete role representation:
{
"expected_policy_version": 12,
"name": "Evidence reviewer",
"description": "Can replay evidence without exporting it",
"actions": ["live.view", "recordings.replay"]
}A delete body contains only expected_policy_version. A custom role cannot be
deleted while a grant references it.
GET /api/authorization/users/{user_id}
PUT /api/authorization/users/{user_id}
Administrator-only. GET returns the user's mode, complete grants, and a policy
version. PUT atomically replaces the complete grant set and mode, and requires
that version as expected_policy_version. An all scope omits a resource; a
selector scope embeds a Fleet 01 selector:
{
"expected_policy_version": 13,
"mode": "policy",
"grants": [
{
"role_uuid": "00000000-0000-4000-8000-000000000003",
"scope": {
"type": "selector",
"selector": {
"version": 1,
"expression": {
"op": "tag_any",
"uuids": ["c401035a-a208-4af9-9bf5-e49da3bd4200"]
}
}
}
}
]
}A collection scope stores a durable reference instead of copying the collection's current selector or members:
{
"role_uuid": "00000000-0000-4000-8000-000000000003",
"scope": {
"type": "collection",
"collection_uuid": "d813e24e-0c7a-48e7-960c-4f5b843466db"
}
}Only shared collections may be authorization scopes. Their current static or smart membership is evaluated at request time, so organizational changes take effect without rewriting every user policy. An in-use collection cannot be made private or deleted, and membership/rule changes advance the policy version.
The server validates every selector, collection, and role before changing
anything. It also
rejects an authenticated administrator's attempt to remove their own effective
users.manage grant. Saving grants in legacy mode is supported so an
administrator can prepare policy before activating default-deny evaluation.
GET /api/authorization/users/{user_id}/tokens
POST /api/authorization/users/{user_id}/tokens
DELETE /api/authorization/users/{user_id}/tokens/{token_uuid}
A user may manage their own tokens; a principal with users.manage may manage
another user's. Token management itself requires a session, Basic auth, or a
legacy API key—a scoped token cannot mint another token. POST requires a
description, an explicit expiry no more than 366 days away, one or more action
keys, and an all-fleet, selector, or shared-collection scope:
{
"description": "North garage PTZ bridge",
"expires_at": 1819075200,
"actions": ["live.view", "ptz.control"],
"scope": {
"type": "collection",
"collection_uuid": "d813e24e-0c7a-48e7-960c-4f5b843466db"
}
}The 201 response contains the secret once as secret plus non-secret token
metadata. lightNVR stores only its SHA-256 hash and a short display prefix.
GET returns metadata, expiry, revocation, last-use time, actions, and scope but
never the secret or hash. DELETE revokes rather than erases the token.
Token authorization is the intersection of the token and its owning user's current effective access, so changing the user policy can only reduce what an existing token can do. During the incremental enforcement rollout, scoped tokens are accepted only by handlers that immediately invoke the central action evaluator (currently scoped PTZ, recording export, evidence protection, and deletion paths). Other legacy handlers reject them rather than risk ignoring a camera scope. The endpoint inventory tracks expansion of that safe surface.
Administrators can manage these credentials from Users → Manage API access. The dialog exposes only actions with current scoped-token endpoint enforcement, supports shared collections and custom selectors, and requires acknowledgment before dismissing a newly displayed secret. The non-expiring legacy key remains in a separate compatibility-only section.
Every initialized HTTP request receives a correlation ID. A caller may supply
X-Request-ID using up to 64 letters, digits, dots, underscores, colons, or
hyphens; otherwise lightNVR generates a UUID. Normal API responses echo the ID
as X-Request-ID, and audit records retain it so an operator can correlate a UI
failure, reverse-proxy log, and durable security decision.
Audit records are append-only through supported APIs. Structured details are generated by the server, and sensitive field names such as passwords, secrets, credentials, authorization headers, cookies, API keys, and raw tokens are redacted before persistence. The initial event coverage includes login outcomes, central authorization decisions, policy simulation and mutations, scoped-token creation/revocation, and retention changes.
GET /api/audit/events
Requires system.admin. Results are ordered newest first and accept these
optional query parameters:
| Parameter | Meaning |
|---|---|
page, page_size |
1-based page; page size defaults to 100 and is capped at 1000 |
since, until |
Inclusive Unix timestamp bounds |
principal_user_id |
Exact local user ID |
action, outcome |
Exact action and outcome (allowed, denied, success, failure, or error) |
target_uuid, request_id |
Exact target or correlation ID |
The response contains page, page_size, page count, complete filtered
total, and an events array. Principal name and authentication method are
snapshotted so the history remains understandable after a user changes.
GET /api/audit/events/export
Accepts the same filters and pagination contract and returns CSV. The filtered
total is exposed as X-Total-Count. CSV cells are quoted and spreadsheet
formula prefixes are neutralized.
GET /api/audit/settings
PUT /api/audit/settings
Requires system.admin. PUT accepts an integer retention period from 1 to
3650 days and prunes already-expired records immediately:
{
"retention_days": 365
}The default is 365 days. Routine audit writes perform an at-most-hourly expiry check, so retention does not depend on a separate scheduler.
Administrators can browse this history from Users → Audit History. The responsive workspace keeps filters server-side, shows structured details on demand, exports the current filtered page, and manages retention without adding another top-level navigation destination.
GET /api/streams
Returns a list of all configured streams.
When authentication is enabled, VIEWER responses contain the operational
fields needed by live view but redact camera credentials and administrative
connection settings (onvif_username, onvif_password, admin_url,
sub_stream_url, detection_url, publish_url, and source overrides). URL
credentials are stripped from url. has_sub_stream preserves the boolean
capability without revealing that URL, and can_control_privacy tells clients
whether to show privacy controls.
Response:
{
"streams": [
{
"id": 0,
"name": "Front Door",
"url": "rtsp://192.168.1.100:554/stream1",
"enabled": true,
"streaming_enabled": true,
"width": 1920,
"height": 1080,
"fps": 15,
"codec": "h264",
"priority": 10,
"record": true,
"segment_duration": 900,
"protocol": 0,
"record_audio": true,
"detection_based_recording": 0,
"detection_model": "",
"detection_threshold": 0.5,
"detection_interval": 10,
"pre_detection_buffer": 0,
"post_detection_buffer": 3,
"detection_api_url": "",
"is_onvif": false,
"onvif_username": "",
"onvif_password": "",
"onvif_profile": "",
"ptz_enabled": false,
"backchannel_enabled": false,
"buffer_strategy": "auto",
"retention_days": -1,
"detection_retention_days": -1,
"record_on_schedule": false,
"detection_record_on_schedule": false,
"status": "connected",
"has_sub_stream": false,
"can_control_privacy": true
}
]
}GET /api/streams/{name}
Returns information about a specific stream by name.
GET /api/streams/{name}/full
Returns complete stream information including all configuration fields.
Per-stream retention values are tri-state: -1 inherits the current global
retention value, 0 is unlimited, and a positive value is a day override.
Continuous and detection-triggered recording have independent weekly schedule
toggles and 168-element Sunday-through-Saturday hour grids.
GET /api/streams/{name}/recording
POST /api/streams/{name}/recording
GET returns idle, starting, or recording, along with the active
capture_method, recording ID, and whether the caller may use manual control.
It is available to viewers who may access the stream.
POST accepts {"action":"start"} or {"action":"stop"}. ADMIN, USER,
and API roles may call it; VIEWER is read-only. Manual start returns 409
if continuous, detection, or another manual recording is active. Manual stop
returns 409 unless the active recording was itself started manually, so it
never interrupts continuous, scheduled, or detection capture.
POST /api/streams
Adds a new stream. All fields from the stream schema are accepted.
PUT /api/streams/{name}
Updates an existing stream.
DELETE /api/streams/{name}
Deletes a stream.
POST /api/streams/test
Tests connectivity to a stream URL.
POST /api/streams/{name}/refresh
Forces a shared source reconnection. Duplicate requests for the same stream are
coalesced while a refresh is active and for a 30-second cooldown afterward;
coalesced requests return 202 with coalesced: true.
GET /api/streams/{name}/retention
Returns retention settings for a specific stream.
PUT /api/streams/{name}/retention
Updates retention settings for a specific stream.
GET /api/streams/{name}/zones
Returns detection zones for a stream.
POST /api/streams/{name}/zones
Creates or updates detection zones for a stream.
DELETE /api/streams/{name}/zones
Deletes detection zones for a stream.
GET /api/streams/{name}/ptz/capabilities
Returns PTZ capabilities for a stream's camera.
POST /api/streams/{name}/ptz/move
Starts continuous PTZ movement.
POST /api/streams/{name}/ptz/stop
Stops PTZ movement.
POST /api/streams/{name}/ptz/absolute
Moves to an absolute PTZ position.
POST /api/streams/{name}/ptz/relative
Performs a relative PTZ movement.
POST /api/streams/{name}/ptz/home
Moves to the home position.
POST /api/streams/{name}/ptz/set-home
Sets the current position as home.
GET /api/streams/{name}/ptz/presets
Lists PTZ presets.
POST /api/streams/{name}/ptz/goto-preset
Moves to a PTZ preset.
PUT /api/streams/{name}/ptz/preset
Creates or updates a PTZ preset.
POST /api/motion/trigger
Drives the same motion-recording path that ONVIF events normally drive. Useful for cameras whose ONVIF event stream is unreliable or missing — the caller (Home Assistant, NodeRED, a shell script, a PIR sensor bridge) can post a motion event and the target stream's detection-based recording pipeline (pre-buffer → recording → post-buffer) handles the rest.
The trigger has two halves, and they are independent:
- Starting a recording requires
detection_based_recordingon the target stream — that is the pipeline the trigger drives. Without it there is no unified detection thread to arm, and the response reportsrecording_triggered: false. - Annotating the event (
label/objects/tags) always happens. A stream on 24/7 recording has nothing to trigger but still gets the detection written to the database, published over MQTT, and drawn on the timeline.
Choosing the stream configuration:
There are two sensible setups, depending on whether you want the trigger to start recordings or just annotate them:
| Goal | detection_based_recording |
detection_model |
|---|---|---|
| The API event starts and stops the recording | enabled | leave empty |
| 24/7 recording, API events only mark the timeline | disabled | n/a |
For the first row, leave the detection model unset: the unified detection thread runs and responds to external triggers, but performs no local inference, so nothing is spent on a model whose output you are not using. Picking a model here means LightNVR also runs that detector on the stream, which is only what you want if you intend to combine local detection with external events.
Authentication: See Authentication. For automation use
a dedicated USER_ROLE_API user and its api_key; USER_ROLE_VIEWER is
rejected with 403.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
stream |
string | yes | Stream name, as shown in the streams list. |
action |
string | yes | One of start, stop, pulse. |
duration_ms |
integer | no | Only valid with pulse. Default 2000, max 600000 (10m). |
label |
string | no | Single object class for the event, e.g. person. |
objects |
array | no | Several classes at once. See below. |
confidence |
number | no | 0–1, default 1.0. Applies to label and to objects entries without their own. |
tags |
array | no | Strings applied to the stream's currently-open recording. Max 16. |
Reporting what was seen (label / objects):
Object classes turn a bare "something moved" into the same shape as a model
detection, so they appear on the timeline, in the detections API, and in the
MQTT payload where Home Assistant can filter them
(selectattr('label','eq','person')) exactly like ONVIF smart events and
model output. Use the same label vocabulary the detectors use (person,
vehicle, bicycle, face, animal, motion).
objects accepts bare strings or per-entry confidences — forward detector
output verbatim, or keep it terse:
{"stream":"Front Door","action":"pulse","objects":["person","vehicle"]}{"stream":"Front Door","action":"pulse",
"objects":[{"label":"person","confidence":0.91},{"label":"vehicle","confidence":0.64}]}Detections are recorded on the leading edge only (start and pulse); a
stop closes the persisted event interval and reports nothing new about what
was seen. The interval spans every recording segment between those edges and
is drawn at its exact start/stop time on the timeline. A trigger without
label or objects is stored as a generic motion event. Supplied objects
cover the whole frame, since an external trigger carries no bounding box, and
are passed through the stream's zone filter just like model detections — a
detection filtered out by zones is not stored, so detections_stored may be
lower than what you sent. At most 20 objects are recorded per event; extras are
ignored.
Tags:
tags are applied to the recording that is currently open for the stream. On a
24/7 stream that is the segment the event falls inside. With detection-based
recording no segment is open at the instant the trigger arrives, so the tags are
not applied and tags_applied comes back 0 — tag those recordings via
PUT /api/recordings/{id}/tags once the recording exists.
Actions:
start— motion began. Equivalent to an ONVIF motion-start event. Recording begins (after the pre-buffer window) and continues until a correspondingstoparrives, at which point the UDT transitions into the post-buffer hold and then closes the recording.stop— motion ended. Mirror ofstart.pulse— convenience: firesstart, waitsduration_ms, then firesstop. One request produces one complete motion event. Ideal for motion sensors that only report a single edge (e.g. a Home Assistant automation that fires when a PIR sensor goes active but does not send a separate "cleared" webhook).
Response: 202 Accepted
{
"success": true,
"stream": "Front Door",
"action": "pulse",
"duration_ms": 5000,
"recording_triggered": true,
"detections_stored": 2,
"tags_applied": 0
}When the stream has no detection-based recording, the event is still recorded
and a warning is included:
{
"success": true,
"stream": "Driveway",
"action": "pulse",
"duration_ms": 5000,
"recording_triggered": false,
"detections_stored": 1,
"tags_applied": 2,
"warning": "Stream does not have detection-based recording enabled; the event was recorded but no recording was triggered"
}Errors:
400— missing/invalid JSON, missingstream/action, badduration_ms, malformedlabel/objects/confidence/tags.401— auth enabled and caller is not authenticated.403— caller isUSER_ROLE_VIEWER.404—streamdoes not match a configured stream.
Changed in 0.36.5: a stream without
detection_based_recordingno longer returns409. The request is accepted andrecording_triggered: falseis reported instead, so 24/7-recording streams can annotate their timeline.
Examples:
Start/stop (matches how an ONVIF sensor would report):
curl -X POST http://lightnvr:8080/api/motion/trigger \
-H "X-API-Key: <key>" -H "Content-Type: application/json" \
-d '{"stream":"Front Door","action":"start"}'
# ...later, when motion clears:
curl -X POST http://lightnvr:8080/api/motion/trigger \
-H "X-API-Key: <key>" -H "Content-Type: application/json" \
-d '{"stream":"Front Door","action":"stop"}'One-shot pulse (Home Assistant rest_command with a PIR sensor):
rest_command:
lightnvr_motion:
url: "http://lightnvr:8080/api/motion/trigger"
method: POST
headers:
X-API-Key: !secret lightnvr_api_key
content_type: "application/json"
payload: '{"stream":"{{ stream }}","action":"pulse","duration_ms":{{ duration | default(5000) }}}'Annotated event from an external detector (what lands on the timeline):
curl -X POST http://lightnvr:8080/api/motion/trigger \
-H "X-API-Key: <key>" -H "Content-Type: application/json" \
-d '{"stream":"Driveway","action":"pulse","duration_ms":10000,
"objects":[{"label":"person","confidence":0.93}],
"tags":["frigate","front-gate"]}'Cross-stream linking (motion_trigger_source in stream config) is honoured:
an API-driven motion event on one stream will also drive recording on any
stream that lists it as a trigger source, same as an ONVIF-driven event. This
happens regardless of whether the source stream itself has detection-based
recording enabled.
GET /api/recordings
Returns a list of recordings. Supports query parameters for filtering by stream
name, date range, and pagination. Pass collection_uuid to filter recordings
by an authorized static or smart camera collection. collection_uuid and
stream are mutually exclusive; collection rules are evaluated on the server
so shared smart rules remain private and large collections do not expand into
query strings.
capture_method is continuous for always-on recording, scheduled when
continuous capture is gated by a weekly schedule, or detection, motion, or
manual for triggered capture. Responses also include schedule_restricted:
true when the capture mode was gated by a weekly schedule, false when it was
unrestricted, and null for recordings created before that metadata existed.
Legacy rows stored as scheduled are reported as continuous only when their
metadata proves they were unrestricted. Timeline segment responses expose the
same nullable field. Timeline responses also include detection_intervals, an
array of exact external-motion start_timestamp/end_timestamp ranges for
the requested window.
GET /api/recordings/{id}
Returns information about a specific recording.
DELETE /api/recordings/{id}
Deletes a recording.
GET /api/recordings/play/{id}
Streams a recording for playback.
GET /api/recordings/download/{id}
Downloads a recording file.
PUT /api/recordings/{id}/protect
Toggles protection status on a recording (protected recordings are exempt from auto-deletion).
PUT /api/recordings/{id}/retention
Sets a per-recording retention override.
POST /api/recordings/batch-delete
Deletes multiple recordings at once. Returns a job ID for progress tracking.
GET /api/recordings/batch-delete/progress/{job_id}
Returns progress for a batch delete operation.
POST /api/recordings/batch-protect
Protects or unprotects multiple recordings at once.
GET /api/recordings/protected
Returns all protected recordings.
GET /api/recordings/files/check
Checks if a recording file exists on disk.
DELETE /api/recordings/files
Deletes a recording file from disk.
POST /api/recordings/sync
Synchronizes the recordings database with files on disk.
GET /api/timeline/segments
Returns recording segments for the timeline view. Supports query parameters for stream name and date range.
GET /api/timeline/manifest
Returns a manifest of available timeline data.
GET /api/timeline/play
Streams video for timeline playback at a specified point in time.
POST /api/investigations/timeline
Returns aligned recording tracks for up to 16 authorized camera UUIDs in a UTC window. Each track includes recording intervals, capture methods, media availability, and explicit gaps used by the synchronized investigation player.
POST /api/investigations/search
Searches persisted detection metadata with stable cursor pagination. Camera
scope is either camera_uuids or a Fleet selector, never both. Explicit camera
lists fail if any requested camera is unauthorized; broad selectors omit
unauthorized matches before totals, facets, and histograms are calculated.
{
"camera_uuids": ["0192a7f0-4f43-4a1d-9e1c-d6947677f145"],
"start_time": 1787529600,
"end_time": 1787533200,
"filters": {
"event_types": ["detection"],
"labels": ["person"],
"zones": ["loading-area"],
"sources": ["local"],
"capture_methods": ["continuous"],
"recording_tags": ["reviewed"],
"locations": ["03852a50-1254-4a0f-894c-cbc660fa6726"],
"protected": true,
"min_confidence": 0.75,
"max_confidence": 1.0
},
"limit": 100,
"cursor": null
}The optional top-level region performs a metadata-only rectangular search on
one camera. Coordinates are normalized to the source image. Matching modes are
center, intersects, and minimum_intersection; the latter accepts a
min_intersection fraction greater than zero and at most one.
{
"region": {
"camera_uuid": "0192a7f0-4f43-4a1d-9e1c-d6947677f145",
"x": 0.1,
"y": 0.2,
"width": 0.4,
"height": 0.5,
"match": "minimum_intersection",
"min_intersection": 0.25
}
}Region search never decodes historical video. The response
coverage.spatial_metadata reports rows with and without valid normalized
bounding boxes. Rows without boxes are not searched spatially, and
spatial_metadata_missing appears in incomplete_reasons so an empty result is
not presented as proof that nothing crossed the selected area.
POST /api/investigations/thumbnail-samples
Returns evenly spaced, authorized sample moments for one camera and a UTC
window. sample_count is optional (default 7) and must be from 3 through 12.
Windows use the same 31-day maximum as the investigation timeline. For windows
shorter than the requested count, the response omits duplicate seconds.
{
"camera_uuids": ["0192a7f0-4f43-4a1d-9e1c-d6947677f145"],
"start_time": 1787529600,
"end_time": 1787529720,
"sample_count": 7
}Each response sample has a timestamp and media_status. Samples covered by a
recording also contain its ID and bounds, an offset_ms, and a lazy thumbnail
URL. Gap samples remain useful for metadata-only time navigation and do not
have a URL. coverage.segments_truncated warns when the interval should be
narrowed before treating sample coverage as complete.
GET /api/investigations/thumbnail/{recording_id}/{offset_ms}
Generates an authorized JPEG at the requested recording offset and caches it
under the recording. The existing thumbnail worker limit and browser request
queue bound concurrent generation; a busy server returns 503 with
Retry-After: 2. Deleting a recording also removes its arbitrary-offset cache
entries.
POST /api/investigations/recordings/preview
Resolves the completed recordings that overlap a fixed list of authorized
camera UUIDs and UTC interval before a protection or convenience-export action.
The request uses the same camera_uuids, start_time, and end_time fields as
the timeline endpoint. The response contains each recording ID, camera, bounds,
size, current protection state, and the caller's can_protect and can_export
decision for that camera. Aggregate permission counts allow the UI to explain a
partial protection result before it occurs; ZIP export remains all-or-nothing.
The preview and existing mutation endpoints independently re-authorize the
request. A preview is therefore advisory and never grants later access. At most
200 overlapping recordings are returned, matching the batch-download limit. A
larger result returns 413 and requires a narrower interval rather than
silently truncating the action set.
GET /api/investigation-bookmarks
POST /api/investigation-bookmarks
GET /api/investigation-bookmarks/{bookmark_uuid}
PUT /api/investigation-bookmarks/{bookmark_uuid}
DELETE /api/investigation-bookmarks/{bookmark_uuid}
Bookmarks are private to the authenticated user and restore an investigation's
camera order, UTC window, shared cursor, primary camera, filters, region, and an
optional representative result. They are navigation aids only:
holds_recordings is always false, so saving a bookmark does not protect
media from retention or deletion. Use the recording protection endpoints when
media must be retained.
Create accepts one through 16 camera UUIDs and a window of at most 31 days:
{
"title": "Loading bay handoff",
"note": "Review before the morning shift",
"camera_uuids": ["0192a7f0-4f43-4a1d-9e1c-d6947677f145"],
"start_time": 1787529600,
"end_time": 1787533200,
"cursor_time": 1787531400,
"primary_camera_uuid": "0192a7f0-4f43-4a1d-9e1c-d6947677f145",
"filters": {
"event_type": "detection",
"label": "person",
"min_confidence": 0.75
},
"representative_result": {
"result_id": "detection:482",
"camera_uuid": "0192a7f0-4f43-4a1d-9e1c-d6947677f145",
"start_time": 1787531400
}
}The service stores only a bounded whitelist of investigation fields; request
credentials, media URLs, and arbitrary result data are not retained. Every
list, read, update, delete, and reopen re-evaluates recordings.replay for all
saved cameras using current policy. List responses omit bookmarks that are no
longer fully visible. Direct access returns 403 for a current policy denial
or 404 if a saved camera no longer exists.
PUT changes only title and note and requires the last observed positive
revision. DELETE accepts a JSON body containing the same revision.
Stale changes return 409. Create, update, and delete outcomes are written to
the audit history. Demo mode returns an empty list and rejects mutations.
POST /api/fleet/cameras/query
Returns an authorized, server-paginated camera inventory with optional facets.
The address field contains only the source scheme and network authority; paths,
query strings, fragments, and embedded credentials are omitted. Existing
allowed_tags restrictions are applied before totals and facet counts are
calculated.
{
"selector": {
"version": 1,
"expression": {
"op": "and",
"children": [
{"op": "location_subtree", "uuid": "location-uuid"},
{"op": "tag_any", "uuids": ["tag-uuid"]},
{"op": "health", "values": ["down", "degraded"]}
]
}
},
"search": "north door",
"collection_uuid": "optional-collection-uuid",
"page": 1,
"page_size": 50,
"sort_by": "name",
"sort_order": "asc",
"facets": true,
"explain": false
}page_size is limited to 200. Supported sort fields are name,
camera_uuid, location, health, enabled, recording_mode, and
address. Results use the selected field plus camera UUID as a stable
tie-breaker.
collection_uuid is optional and composes with selector and search. The
collection must be shared, owned by the caller, or requested by an
administrator. Collection membership and the caller's allowed_tags scope are
applied before totals, pages, and facets are calculated. A collection that is
not visible to the caller returns 404.
Selector version 1 supports:
- Boolean nodes:
andwithchildren,orwithchildren, andnotwithchild. all.camera_uuidwithvalues.location_subtreewithuuid.tag_any,tag_all, andtag_nonewith taguuids.enabledwith a booleanvalue.recording_modewithvaluesfromoff,continuous, anddetection.vendorandmodelwith case-insensitivevalues. Inventory values are populated as ONVIF inventory support becomes available.capability_anyandcapability_allwithvaluesfromonvif,ptz, andbackchannel.healthwithvaluesfromunknown,up,degraded,down, anddisabled.
Selectors are limited to 8 levels, 64 nodes, and 64 values per node.
POST /api/fleet/selectors/preview
Accepts the same request as the query endpoint, caps pages at 50 cameras, and
adds matched_clauses to each returned camera. An optional camera_uuid
restricts the preview to one camera.
Collections are durable named camera groups. A static collection stores UUID
membership; a smart collection stores a selector v1 object and updates as
cameras, locations, tags, configuration, or health change.
GET /api/camera-collections
POST /api/camera-collections
Listing requires viewer access and returns only shared collections, collections
owned by the caller, or all collections for administrators. Counts are computed
after current tag RBAC. Smart selector definitions are returned only to an
administrator or the collection owner; other viewers receive selector: null
and selector_redacted: true. Creation is administrator-only.
{
"name": "Offline entrances",
"description": "Entrance cameras requiring attention",
"type": "smart",
"shared": true,
"selector": {
"version": 1,
"expression": {
"op": "and",
"children": [
{"op": "tag_any", "uuids": ["entrance-tag-uuid"]},
{"op": "health", "values": ["down"]}
]
}
}
}GET /api/camera-collections/{collection_uuid}
PUT /api/camera-collections/{collection_uuid}
DELETE /api/camera-collections/{collection_uuid}
Reads follow collection visibility and camera RBAC. Update and delete are
administrator-only in this initial phase. Switching a collection to smart
atomically removes obsolete static membership.
GET /api/camera-collections/{collection_uuid}/members
PUT /api/camera-collections/{collection_uuid}/members
PUT replaces membership atomically with a camera_uuids array and is limited
to 4,096 entries. GET omits cameras outside the caller's current scope. Smart
collections reject explicit member operations.
POST /api/camera-collections/{collection_uuid}/preview
Returns the authorized matched_count and a sample of at most 50 camera UUIDs,
names, and location paths.
Storage target endpoints require the global storage.configure action. They
register local directories or administrator-mounted filesystems as stable,
revisioned recording destinations and return cached capacity/health data.
GET /api/storage-targets
POST /api/storage-targets
GET /api/storage-targets/{target_uuid}
PUT /api/storage-targets/{target_uuid}
DELETE /api/storage-targets/{target_uuid}?revision={last_seen_revision}
POST /api/storage-targets/{target_uuid}/probe
An upgrade automatically creates one default target from the active recording
root. Existing recording rows below that root gain a target UUID and relative
object key without moving files. Absolute file_path remains a compatibility
cache during the transition. The default target cannot be disabled, deleted, or
repointed. Any other target that owns recording rows also keeps an immutable
root and cannot be deleted until a future lifecycle operation relocates those
rows.
Create accepts the following shape. v1 target type is always filesystem;
root_path must be an absolute path other than / and cannot contain traversal
segments. Enabled targets must already exist and pass a write/fsync/unlink test.
An unavailable future mount may be saved with enabled: false and enabled
later.
{
"name": "Campus NAS hot 01",
"root_path": "/mnt/lightnvr/hot-01",
"enabled": true,
"storage_class": "hot",
"reserve_bytes": 107374182400,
"low_watermark_pct": 80,
"high_watermark_pct": 90
}storage_class is hot, warm, or cold. Watermarks describe percent used
and must satisfy 0 <= low < high < 100. Updates are partial but require the
last observed positive revision; deletes use that revision as a query
parameter. The explicit probe performs a small temporary write, fsync, and
unlink and then refreshes cached health.
List and item responses include health.status, capacity, available and used
bytes, health.pressure, health.cleanup_target_bytes, last probe/success
times, and the last error. They also include indexed
recording count/bytes maintained by SQLite triggers rather than scanning the
recordings table. health.duplicate_filesystem warns when two roots have the
same underlying device ID, preventing later capacity planning from counting one
filesystem twice.
An enabled target enters pressure cleanup at its high watermark or when reserved headroom is breached. Cleanup selects only complete, unprotected, pressure-eligible recordings assigned to that target and works back toward the low watermark (or reserve, whichever requires more free bytes). Cleanup is bounded per heartbeat and never borrows candidates from another target. The legacy global capacity and emergency paths are restricted to the default target.
Storage placement policy endpoints also require storage.configure:
GET /api/storage-policies
POST /api/storage-policies
POST /api/storage-policies/preview
GET /api/storage-policies/{policy_uuid}
PUT /api/storage-policies/{policy_uuid}
DELETE /api/storage-policies/{policy_uuid}?revision={last_seen_revision}
Policies use a Fleet selector, integer priority, primary target, and an explicit
default, named target, pause, or fail fallback. Higher priority wins;
ties are stable by case-insensitive policy name and UUID. Placement is evaluated
for each newly opened recording segment and does not move existing footage.
The preview endpoint accepts the same draft as create. An edit draft also sends
its uuid and revision. It does not mutate policy state. The response reports
matched_camera_count, effective_camera_count, shadowed_camera_count, and
conflicts grouped by existing policy, plus a bounded 50-camera sample showing the
effective winning policy and target. This lets the editor expose overlap and
effective precedence before save.
Event route endpoints require the global events.configure action (legacy
administrators have it). They expose the registered event catalog and a durable,
revisioned route control plane. The normalized MQTT publisher evaluates enabled
routes before durable enqueue; the preview endpoint never publishes.
GET /api/events/catalog
Returns every registered event type with its family, description, severity, sensitivity, media policy, expected rate, subject kind, and default expiry.
GET /api/event-destinations
POST /api/event-destinations
GET /api/event-destinations/{destination_uuid}
PUT /api/event-destinations/{destination_uuid}
DELETE /api/event-destinations/{destination_uuid}?revision={last_seen_revision}
These endpoints manage up to 64 named MQTT broker profiles. All operations
require events.configure. The list response also describes the unmanaged
mqtt:default destination backed by the existing [mqtt] settings.
Create requires name and broker.host. It defaults to port 8883, system
certificate trust, QoS 1, a 60-second keepalive, a unique lightnvr-… client
ID, and topic template lightnvr/v1/events/{type}/{subject_id}.
{
"name": "Operations bridge",
"description": "Input for the hosted notification service",
"enabled": true,
"type": "mqtt",
"broker": {
"host": "mqtt.example.net",
"port": 8883,
"client_id": "lightnvr-campus-a",
"topic_template": "campus-a/{type}/{subject_id}",
"keepalive_seconds": 60,
"qos": 1
},
"authentication": {
"username": "event-publisher",
"password": "write-only-secret"
},
"tls": {
"mode": "system"
}
}tls.mode is one of disabled, system, custom_ca, or mutual. Custom
certificate paths must be absolute; custom_ca requires ca_file, and
mutual requires ca_file, cert_file, and key_file.
Topic templates must contain {type} and {subject_id} and cannot contain
MQTT wildcards.
Passwords are write-only. Responses contain only
authentication.password_configured; they never return the credential. On
update, omitting authentication.password preserves it, while JSON null or
an empty string clears it. Updates are partial but require the last observed
positive revision, and deletes use the same revision as a query parameter.
Names are unique case-insensitively, as is the broker host, port, and client ID
combination. A profile referenced by an event route or an active durable outbox
row cannot be deleted. Delivered and dead history does not block deletion.
Profile create, update, and delete outcomes are written to the audit history
without credential material.
Each enabled profile has an independent reconnecting MQTT client. Routes may
use mqtt:default or the mqtt:<destination_uuid> key returned by these
endpoints. Disabling a profile pauses delivery for its durable queue without
discarding unexpired events; re-enabling or updating it rebuilds the client
from the latest revision. The password remains write-only during that reload.
GET /api/event-routes
POST /api/event-routes
GET /api/event-routes/{route_uuid}
Create accepts a complete route definition. Only name and event_types are
required; omitted fields use the defaults shown below. Unknown fields and
unknown event types are rejected. destination must be mqtt:default or the
key of an existing managed MQTT destination profile.
{
"name": "North entrance people",
"description": "External notification input",
"enabled": true,
"destination": "mqtt:default",
"event_types": ["io.lightnvr.detection.object.v1"],
"camera_scope": {
"type": "selector",
"selector": {
"version": 1,
"expression": {
"op": "location_prefix",
"values": ["Campus/North"]
}
}
},
"predicate": {
"version": 1,
"detection": {
"labels_any": ["person"],
"min_confidence": 0.8,
"zone_ids_any": ["entry"]
}
},
"schedule": {
"version": 1,
"timezone": "America/New_York",
"windows": [
{"days": [1, 2, 3, 4, 5], "start": "18:00", "end": "06:00"}
]
},
"suppression": {
"debounce_seconds": 2,
"cooldown_seconds": 30,
"grouping_window_seconds": 10,
"max_events_per_minute": 20
}
}An all-camera scope is {"type":"all"}. Defaults are enabled, all cameras,
{"version":1} predicate, an always-active UTC schedule, and zero for each
suppression value. A successful create returns 201 with the server-assigned
UUID, revision 1, and timestamps. Names are unique case-insensitively and at
most 512 routes may be stored.
Timezone names must resolve under /usr/share/zoneinfo (with UTC and GMT
always accepted). Schedules are evaluated against event occurrence time and
support DST-aware overnight windows.
Suppression is durable and isolated by route UUID, event type, and subject:
debounce_secondssuppresses a repeat inside the interval since the latest observation; each suppressed repeat extends the interval.cooldown_secondsstarts when an event is accepted by the outbox; suppressed repeats do not extend it.grouping_window_secondspreserves the first event and coalesces repeats for the window. Version 1 does not emit an aggregate summary event.max_events_per_minutelimits allowed events in a fixed 60-second window.
Checks run in that order. An allowed event advances suppression state only after
durable outbox acceptance (ENQUEUED or idempotent DUPLICATE), so queue-full
and persistence errors do not consume cooldown or rate budget. Editing a route
clears its prior suppression state, and inactive state is pruned after 30 days.
One normalized envelope is persisted for each unique destination matched by at least one route. Multiple matching routes to the same destination do not create duplicate publishes. The destination's topic template is expanded and frozen when the outbox row is created, so later profile edits affect new events without changing already accepted work. Fan-out is independent: acceptance or failure for one destination does not consume another destination's suppression state.
With zero stored routes, normalized MQTT retains its compatibility publish-all
behavior through mqtt:default when the legacy MQTT setting is enabled. Once
any route is stored, only events matching at least one enabled route are
enqueued to that route's destination. Disabling all stored routes pauses
normalized enqueue; deleting the last route restores the default. Managed
destinations continue to run when the legacy/default MQTT setting is disabled.
This does not filter legacy detection or Home Assistant compatibility topics.
PUT /api/event-routes/{route_uuid}
DELETE /api/event-routes/{route_uuid}?revision={last_seen_revision}
Update is a partial write but must include the last observed positive
revision. Delete carries the same value as a query parameter. A stale write
returns 409; a successful update increments the revision. Create, update, and
delete outcomes are recorded in the audit history. Any successful update resets
the route's durable suppression history so the revised policy starts cleanly.
POST /api/event-routes/preview
Accepts the same complete body as create, validates every field, and resolves
the camera selector against the current Fleet inventory. The response includes
matched_camera_count, a camera_sample of at most 20 entries, registry
metadata for the selected event types, and would_publish: false. It neither
persists the draft nor enqueues or publishes an event.
GET /api/system
GET /api/system/info
Returns system information including version, uptime, CPU/memory/storage usage, and stream counts.
The response also includes a versions.items array summarizing runtime-detected software versions such as the base OS, LightNVR, optional services, and linked libraries.
GET /api/system/status
Returns system health status.
GET /api/system/logs
Returns recent system log entries.
POST /api/system/logs/clear
Clears the system log file.
POST /api/system/restart
Restarts the LightNVR service.
POST /api/system/shutdown
Shuts down the LightNVR service.
POST /api/system/backup
Creates a backup of the database.
GET /api/settings
Returns system configuration settings.
The storage fields include mp4_directory_format, one of flat,
year_month, or year_month_day.
POST /api/settings
Updates system configuration settings.
mp4_directory_format accepts only the three safe presets returned by the
GET endpoint; arbitrary strftime templates are rejected with HTTP 400.
GET /api/health
Returns basic health status.
GET /api/health/hls
Returns HLS streaming subsystem health.
GET /api/ice-servers
Returns WebRTC ICE server configuration (STUN/TURN servers).
POST /api/auth/login
Authenticates a user and creates a session.
Request Body:
{
"username": "admin",
"password": "yourpassword"
}Success Response:
{
"success": true,
"redirect": "/index.html",
"must_change_password": false
}On a fresh installation created with the fallback admin password,
must_change_password is true. That password-authenticated session can only read
/api/auth/verify and change its own password until the replacement succeeds. MFA is
deferred until the next login. Demo mode and API-key authentication are unaffected.
POST /api/auth/login/totp
Completes login with a TOTP code (for users with MFA enabled).
Request Body:
{
"totp_token": "pending_session_token",
"code": "123456"
}POST /api/auth/logout
GET /logout
Destroys the current session.
GET /api/auth/verify
Verifies that the current session is valid.
The response includes must_change_password, allowing the blocking first-login flow to
recover safely after a refresh.
GET /api/auth/users
Returns all users (admin only).
POST /api/auth/users
Creates a new user.
GET /api/auth/users/{id}
Returns a specific user.
PUT /api/auth/users/{id}
Updates a user.
DELETE /api/auth/users/{id}
Deletes a user.
POST /api/auth/users/{id}/api-key
Generates an API key for a user.
PUT /api/auth/users/{id}/password
Changes a user's password.
PUT /api/auth/users/{id}/password-lock
Locks or unlocks a user's password from being changed.
POST /api/auth/users/{id}/totp/setup
Initiates TOTP setup, returns secret and QR code URI.
POST /api/auth/users/{id}/totp/verify
Verifies a TOTP code during setup to confirm it works.
POST /api/auth/users/{id}/totp/disable
Disables TOTP for a user.
GET /api/auth/users/{id}/totp/status
Returns whether TOTP is enabled for a user.
GET /api/onvif/discovery/status
Returns ONVIF discovery service status.
POST /api/onvif/discovery/discover
Triggers an ONVIF device discovery scan.
GET /api/onvif/devices
Returns discovered ONVIF devices.
GET /api/onvif/device/profiles
Returns media profiles for an ONVIF device.
POST /api/onvif/device/add
Adds a discovered ONVIF device as a stream.
POST /api/onvif/device/test
Tests connectivity to an ONVIF device.
GET /api/detection/results/{stream_name}
Returns recent detection results for a stream.
Each result includes timestamp and end_timestamp. They are equal for
instantaneous model detections; external motion events use the persisted
start/stop interval.
GET /api/detection/models
Returns available detection models.
POST /api/motion/test/{stream_name}
Triggers a test motion event for debugging.
GET /hls/{stream_name}/{filename}
Serves HLS playlist (.m3u8) and segment (.ts) files for live streaming.
All API endpoints return appropriate HTTP status codes:
- 200: Success
- 400: Bad Request
- 401: Unauthorized
- 404: Not Found
- 500: Internal Server Error
Error responses include a JSON object with an error message:
{
"error": "Stream not found"
}Login and list streams:
# Login
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
-d '{"username":"admin","password":"yourpassword"}' \
http://your-lightnvr-ip:8080/api/auth/login
# List streams
curl -b cookies.txt http://your-lightnvr-ip:8080/api/streams
# Get system information
curl -b cookies.txt http://your-lightnvr-ip:8080/api/system
# Add a new stream
curl -b cookies.txt -X POST -H "Content-Type: application/json" \
-d '{"name":"New Camera","url":"rtsp://192.168.1.103:554/stream1","enabled":true,"width":1280,"height":720,"fps":10,"codec":"h264","priority":5,"record":true}' \
http://your-lightnvr-ip:8080/api/streams
# Trigger ONVIF discovery
curl -b cookies.txt -X POST http://your-lightnvr-ip:8080/api/onvif/discovery/discover