The recorder's API for other systems
Another system talks to EverNVR through the same HTTP routes its own pages
use, under the same permissions, signed in with an API token instead of a
browser session. The full route list, generated from the running recorder, is
at GET /api/openapi.json (OpenAPI 3.1; sign in to read it). This page is the
guide to the parts an integration reaches for.
Tokens
Settings → API tokens (an owner administrator). A token stands for one account: it sees that account's cameras and may do what that account's role may do, so choose the least account that will serve: a viewer granted only the cameras the other system needs, an operator only if it must move a camera, switch its light or siren, or press a button. A read-only token is refused every write (no PTZ, no button presses, no settings) before a route sees it. The secret is shown once; only a hash is kept. A token is revoked in Settings → API tokens without touching anyone's password, and revoking closes its open streams and feeds at once. Creation and revocation are in the account history.
A token may be given an expiry (up to five years). After it, the token is refused exactly as a revoked one is, with no job to run and nothing to remember. Give temporary work — a commissioning laptop, a trial integration — an expiry; leave it empty for a permanent integration.
Present the token one of three ways:
| Where | How |
|---|---|
| Any request | Authorization: Bearer evk_… |
| A stream URL for a player or ffmpeg | HTTP basic auth with the token as the password: https://token:evk_…@recorder/api/cameras/2/stream.mjpg |
| Where a client cannot send a header | ?token=evk_… on a GET of stream.mjpg, frame.jpg, playback.mp4, /ws or /ws/video |
A URL is written down: by proxies, by browser history, in a Referer. So the
query parameter is accepted only on the routes whose client is an <img>, a
player or a browser's WebSocket and has no way to send a header. Everywhere
else, use the header or basic auth. An Authorization header this recorder
did not issue is ignored rather than refused, so a client with a bearer token
for something else can still reach the sign-in page.
Tokens are minted and revoked by a signed-in owner administrator on the API
page. A token cannot mint or revoke tokens, whatever account it stands for;
POST /tokens and DELETE /tokens/:id answer a token with 403.
A camera with privacy masks refuses /ws/video to a token (close code
1008). That socket carries the camera's own packets, and only the recorder's
own page draws the masks over them. A token gets the same camera through
stream.mjpg, frame.jpg and playback.mp4, which have the masks applied
before the pixels leave; playback.mp4 of a masked camera is re-encoded and
starts at the moment asked for.
Times are epoch milliseconds throughout. Camera ids are the id in /api/cameras.
What an NVR integration uses
Administrators can read first-run wizard progress at GET /api/onboarding:
{"step":"hailo","required":true}. PATCH /api/onboarding accepts a step
of hailo, storage, ai, or complete; only complete makes required
false. This records wizard progress, not hardware qualification or a successful
configuration check. The wizard saves configuration through the existing
PUT /api/detector, PATCH /api/storage/location,
PATCH /api/storage/settings, and PUT /api/ai routes before advancing.
GET /api/status includes onboarding for administrators and null for
other callers. Existing installations without wizard progress are treated as
complete; creating the first account through POST /api/setup starts at
hailo.
| Purpose | Route |
|---|---|
| Who am I, version, camera count | GET /api/status — also timeZone, the recorder's own zone, which every time the recorder shows is in; null before signing in |
| Which recorder this is | GET /api/identity — product, a stable id and name (the commissioned site name, else the host name). Answers without signing in and carries no version; it is what the mobile app uses to recognize a recorder whose address has changed |
| Cameras | GET /api/cameras — id, name, enabled, onvif (PTZ possible), recordProfile (width, height, codec, fps), health.state, mode (intelligence_only means the recorder keeps no footage), cameraEvents (whether the recorder listens to the camera's own events), cameraEventsStatus (state starting, ok or failing, reason, lastEventAt, active kinds), outputs (the deterrence outputs last found on the camera, each id, kind: relay, light or siren, name and driver: onvif, dahua or tapo), outputsProbedAt (null until an administrator has looked), outputsNotes (what a driver could not do, in words), vendorAccount (whether a Tapo app account password is saved), talkback (whether an operator can talk through the camera's speaker: supported, codec, probedAt, null until the recorder has asked, and reason when not supported) and planPlacement (where the camera is on the site plan: planId, x, y, headingDeg, hfovDeg and calibrated, which is homography, ptz or null; null when the camera is on no plan) |
| PTZ capability and presets | GET /api/cameras/{id}/ptz, GET /api/cameras/{id}/ptz/presets |
| Live frame | GET /api/cameras/{id}/still.jpg |
| Live video | GET /api/cameras/{id}/stream.mjpg — MJPEG, plays in any player or ffmpeg; the recorder drops frames rather than letting a slow client fall behind |
| Frame from the recording | GET /api/cameras/{id}/frame.jpg?at=<ms>&width=<px> — 404 when no footage is kept for that moment. A frame is a pull of footage: it holds one of the account's playback slots while it is cut, at most 30 a minute per account (429 too_many_frames), and a moment older than the 120-second review window needs the account's export permission and is audited as an export. Responses are never cached: a privacy mask drawn today has to cover a frame cut from last week, and a cached copy is a look at footage the recorder never saw |
| Recorded ranges | GET /api/cameras/{id}/availability?from=<ms>&to=<ms> — ranges: [{start, end}] and bounds |
| Recorded ranges, every camera at once | GET /api/recordings/availability?from=<ms>&to=<ms> — a window of up to 31 days, both bounds required; cameras: {<id>: {cameraId, bounds, ranges}} for each camera the account may see, each entry what that camera's own route returns |
| Recorded video | GET /api/cameras/{id}/playback.mp4?start=<ms>&duration=<s> — download=1 needs the account's export permission |
| Boxes over the recording | GET /api/cameras/{id}/tracks?from=<ms>&to=<ms> — where each detected object was, frame by frame, at most 10 minutes per call; boxes are fractions of the frame, timestamps are on the recording's clock (the camera's box alignment already applied and returned as offsetMs: in auto the recorder's measurements nearest the requested window, in manual the camera's trackOffsetMs), and they are kept exactly as long as the footage they belong to |
| Events | GET /api/events?cameraId=&label=&who=&plate=&since=&until=&limit= — newest first; who is known, unrecognized, unsure or an identity id; plate is all or part of a license plate (letters and digits; spaces and punctuation are ignored). Where face or plate recognition is on, an event also carries identity_method (appearance, face or plate), face_state (known, unsure, unknown or unusable), and plate_text, plate_state (known, unsure or unknown) and plate_confidence |
| Event picture | GET /api/events/{id}/thumbnail.jpg |
| Activity over time | GET /api/events/density?from=<ms>&to=<ms>&buckets=<n> — how many events began in each of buckets equal slices (default 120, at most 500) of a window of up to 31 days, per camera the account may see: cameras: {<id>: [{bucket, total, firstAt, person, vehicle, animal, other}]} with empty slices left out, plus from and bucketMs to place them |
| What rules did | GET /api/alerts?limit= |
| What cameras reported themselves | GET /api/camera-events?cameraId=&limit=&before=<ms> — newest first, events: [{camera_id, camera_name, kind, phase, topic, at, summary}]. kind is motion, line, intrusion, person, tamper or other, and phase is begin, end or pulse (happened once). Only cameras with cameraEvents on report anything; these are the camera's own ONVIF claims, not the recorder's detections |
| Buttons | GET /api/action-buttons, POST /api/action-rules/{id}/press (operator) |
| Rules (administrator) | GET /api/action-rules, POST /api/action-rules, PATCH and DELETE /api/action-rules/{id} — a rule carries actions, a list of up to ten, each a type of notify (userIds, channels), webhook (url, write-only secret), ptz_preset (cameraId, preset, and optionally returnPreset with returnAfterSeconds 5..3600, 60 unless given), ptz_aim (cameraId of a PTZ whose heading is calibrated on the site plan, zoom 0..1 or null to keep, and the same optional return), output (cameraId, outputId from that camera's outputs, state: pulse, on or off, and seconds 1..300 for a pulse, 10 unless given), talk_message (cameraId of a camera whose talkback.supported is true, messageId of a saved message, repeat 1..3, 1 unless given), preserve (beforeSeconds, afterSeconds, up to an hour in all) or arm (armed, ruleIds, minutes: 0 for until changed, up to a week). action is the first of them, kept for older clients, and a body with action alone is still accepted. A webhook's answer says hasSecret, never the secret; a save that omits secret keeps the saved one for the n-th webhook, and secret: null removes it. temporary_until is when a timed arm or disarm ends, null otherwise; activeNow is whether the rule is listening right now |
| Alarm journal | GET /api/alarms?source=&severity=&cameraId=&since=&until= |
| Site plan | GET /api/site-plans — the plans (id, name, width, height in pixels; the image is GET /api/site-plans/{id}/image) and placements keyed by camera id: x, y in plan pixels, headingDeg clockwise from up, hfovDeg, and calibration (kind homography for a fixed camera matched to the plan, with its points and residualPx, or ptz with headingDeg, height in plan pixels and residualDeg). The answer also carries limits (bytes, pixels, points and the placement ranges). Administrators upload a plan with POST /api/site-plans?name= (the JPEG or PNG as the body, at most 8 MB and 8000 pixels on a side), replace its image with PUT /api/site-plans/{id}/image (pins are scaled along, calibrations dropped), rename it with PATCH /api/site-plans/{id} {"name"} and remove it with DELETE /api/site-plans/{id}, which takes every camera off it. They place a camera with PUT /api/cameras/{id}/plan {"placement": {"planId", "x", "y", "headingDeg", "hfovDeg", "rangePx"}} in plan pixels, or {"placement": null} to take it off; match a fixed camera's picture with PUT /api/cameras/{id}/plan/points {"points": [{"picture": [u, v], "plan": [x, y]}]} (picture fractions, at least four, up to 24; the answer's fit carries residualPx and each point's errorsPx, and an empty list clears it); and measure a PTZ's heading with PUT /api/cameras/{id}/plan/samples {"samples": [{"position": {"pan", "tilt", "zoom"}, "plan": [x, y]}]} (the camera needs a calibrated or trained PTZ geometry; the answer's fit carries headingDeg, height and residualDeg, and an empty list clears it). GET /api/cameras/{id}/plan/looking answers the plan point under a calibrated PTZ's center and its position, or null when it looks at or above the horizon; POST /api/cameras/{id}/plan/aim {"x", "y", "zoom"?} sends a calibrated PTZ to look at a plan point (PTZ operator) and answers like ptz/aim plus bearingDeg and levelOnly (true when the camera's height is unknown and only its pan was set); 409 for a camera whose heading is not calibrated |
| Health | GET /api/health (administrator) |
| A camera's clock (administrator) | GET /api/cameras/{id}/clock — reads the camera now and answers driftSeconds, the camera's clock minus the recorder's, offsetMinutes, the camera's local time minus its UTC time (null when it reports only one), and dateTimeType, NTP or Manual. POST /api/cameras/{id}/clock {"switchToManual": false} sets the camera's time as the signed-in administrator would from the camera's page, and with it the recorder's time zone and the recorder as its NTP server: verified, driftBeforeSeconds, driftAfterSeconds, timeZoneBefore, timeZoneSet (null when the camera already showed the recorder's offset and kept its own zone string), timeZonePreserved, zoneVerified (whether the camera's picture time now shows the recorder's offset; null when it cannot be told), ntpServerSet (the recorder address the camera now takes its time from), ntpError (the camera's answer when it refused an NTP server), dateTimeTypeBefore, dateTimeTypeSet, streamReopened, and ntpKept when the camera stayed on NTP and did not hold the time; switchToManual: true is the separately audited switch to manual time and sets no NTP server. 400 for a camera without ONVIF |
| Automatic clock correction (administrator) | GET /api/clock-sync and PUT /api/clock-sync {"enabled": true} — whether the recorder itself sets a camera's clock, time zone and NTP server when it finds the time seconds out, the zone different from the recorder's, or the camera not taking its time from the recorder (on by default). Off, it still reads every camera's clock on its schedule; each correction it makes is in GET /api/cameras/{id}/writes with no performed_by |
| The recorder's own clock (administrator) | GET /api/recorder/time — available (false, with error, when the system's clock service cannot be reached), zone, ntp (network time on), canNtp, synchronized, now (the recorder's time in ms), zones (the zones it can be set to), pinnedZone (the zone EVERNVR_TZ pins times to, otherwise null) and timeServer (whether the recorder is serving NTP to cameras). PUT /api/recorder/time with any of {"zone": "<from zones>", "ntp": true|false, "time": "YYYY-MM-DD HH:MM"} changes them and answers the same fields without zones. A time is read in the recorder's zone after any new zone is applied, and is refused unless network time is off or turned off in the same request. 400 for a value it refuses, 502 when the system's clock service refuses the change. Each change is recorded in the alarm journal, and every camera's clock is checked again at once |
PTZ (operator or administrator; the account, not the token, decides):
| Verb | Route and body |
|---|---|
| Continuous move | POST /api/cameras/{id}/ptz/move {"pan": -1..1, "tilt": -1..1, "zoom": -1..1, "durationMs": optional} — the recorder stops the move itself after a short timeout |
| Stop | POST /api/cameras/{id}/ptz/stop |
| Aim at a point in the picture | POST /api/cameras/{id}/ptz/aim {"x": -1..1, "y": -1..1, "zoom": -1..1} — the answer says how it was done: method is absolute when the camera's calibrated or trained geometry worked out the pan and tilt and the recorder checked and corrected the result, or fov when the camera was aimed in its own terms; predictedError and achievedError are how far the point was expected to be and ended up from center, as a fraction of the frame; corrections is how many follow-up moves it took, and clamped is true when the camera could not reach the point and was turned as far as it goes |
| Recall a preset | POST /api/cameras/{id}/ptz/preset {"token": "<preset token>"} |
| Save a preset (administrator) | POST /api/cameras/{id}/ptz/presets {"name": "…"} |
| Patrol and what it is doing | GET /api/cameras/{id}/ptz/patrol (anyone who can see the camera) — patrol (steps: [{preset, presetName, dwellSeconds}], enabled, schedule, resumeAfterSeconds) or null, and status.state: running, paused (after another PTZ command, until status.until), held, waiting (status.reason) or off |
| Define the patrol (administrator) | PUT /api/cameras/{id}/ptz/patrol {"steps": [{"preset": "<token>", "dwellSeconds": 10..3600}], "enabled": true, "schedule": {"days": [0..6], "from": "HH:MM", "to": "HH:MM"} or null, "resumeAfterSeconds": 30..3600} — every preset must be on the camera; DELETE removes it |
| Hold or resume the patrol | POST /api/cameras/{id}/ptz/patrol/pause, POST /api/cameras/{id}/ptz/patrol/resume — a hold lasts until resumed, across restarts |
| Auto tracking and what it is doing | GET /api/cameras/{id}/ptz/follow (anyone who can see the camera) — follow (enabled, groups, maxFollowSeconds, returnPreset, returnPresetName, returnAfterSeconds, resumeAfterSeconds) or null; unavailable, a sentence saying what the camera lacks (trained geometry, absolute positioning) or null; and status.state: watching, following (status.label, status.since, status.hops), paused (until status.until) or off, with status.lastEnded (why: lost, time, operator, rule, moved, stopped or failed) |
| Set auto tracking (administrator) | PUT /api/cameras/{id}/ptz/follow {"enabled": true, "groups": ["person", "vehicle"], "maxFollowSeconds": 10..600, "returnPreset": "<token>" or null, "returnAfterSeconds": 0..600, "resumeAfterSeconds": 10..3600} — switching it on needs the camera's PTZ geometry calibrated or trained and an absolute position space (409 otherwise); the return preset must be on the camera; DELETE removes it |
| Stop following | POST /api/cameras/{id}/ptz/follow/stop — the camera stays where it is and auto tracking pauses for resumeAfterSeconds, as after any other PTZ command |
| Deterrence outputs | GET /api/cameras/{id}/outputs (anyone who can see the camera) — the camera's relay, light and siren outputs as last found (items: [{id, kind, name, driver, on, until}], with on what the recorder last set and until when a timed switch ends, or null), plus probedAt and notes; POST /api/cameras/{id}/outputs/{outputId} (operator) with `{"state": "pulse" |
| Spoken messages | GET /api/talk/messages (anyone signed in) — the saved messages, `{id, name, source: "recorded" |
Any PTZ command above pauses a running patrol for its resumeAfterSeconds,
and ends a follow and pauses auto tracking for its own resumeAfterSeconds.
A rule's PTZ action ends a follow without pausing auto tracking.
The status socket sends {"type": "ptz-patrol", "cameraId", "patrol"} and
{"type": "ptz-follow", "cameraId", "follow"} on every change, and {"type": "camera-output", "cameraId", "id", "on", "until"} each
time an operator or a rule switches an output; a timed switch ending sends
nothing, so read until. GET /api/cameras/{id}/writes?limit=<n>
(administrator) returns a camera's newest audited writes, each patrol step and
each output switch (operation output, with the output, the state asked for,
the rule if one asked, and the camera's answer when it refused) included, as
well as every aim at a plan point or a trigger (ptz_aim, with the plan point
and bearing when the plan was used), every return a rule booked
(ptz_return, with the preset and the rule), and one row per follow
(ptz_follow, with what was followed, for how long, how many moves and why it
ended) and per return after one (ptz_follow_return).
Deep links for a person: /playback?camera=<id>&at=<ms> opens the timeline at
that moment; /events?event=<id> opens an event; /?camera=<id> opens the
live view on a camera.
Hearing about things
Three ways, and an integration usually wants more than one.
- Webhooks, pushed. An action rule with a webhook action POSTs its
trigger details to your URL, signed with the action's secret (HMAC in the
X-EverNVR-Signatureheader); a rule may carry several actions, webhooks among them, and the body names the rule (rule,ruleId). Rules can fire on detections, departures, occupancy, absence, button presses, recorder faults, departures from the site's own normal and the camera's own ONVIF events (triggerTypecamera_event, withcameraEvent.kindandcameraEvent.topic). When the recorder knows who it was, the body carriesidentity(name, andmethod:appearance,faceorplate); where recognition read a plate or a face, it carriesplate(text,state) andface(the face reading). A detection carriesregion, the object's box as fractions of the frame[left, top, right, bottom]. See INSTALL.md. - The status feed, live.
wss://recorder/wswith the token as a bearer header or?token=. Messages:hello(cameras this token may see),camera-state,camera-activity,event,camera-event,alert,detections,ptz-patrol,ptz-follow,camera-output,site-plan-changed(a plan or a camera's place on it changed, withcameraIdwhen it was one camera's pin; the client reloads/api/site-plans),license-changed(with the newstate; the client reloads/api/license),heartbeatevery 15 s. An account limited to some cameras gets each of these for the cameras it may see, output switches included; anything about a camera the account cannot see is never sent. - MQTT, published. An administrator can point the recorder at an MQTT broker (Settings → MQTT): detections, alerts, alarms and retained camera and recorder state under one topic prefix. Unlike the status feed it is not filtered per account. See the MQTT guide.
Polling GET /api/events?since=<last seen> every few seconds is the simplest
correct client and is what the GEM driver does.
The license
A recorder installed with the EverNVR installer is licensed; a source checkout or development server has no license and refuses nothing. The license never stops recording, a camera already added or a feature already switched on. It refuses growth: adding a camera past the licensed count, switching a licensed feature on, and new work in one.
GET /api/license (anyone signed in) answers {"managed": false} on a
recorder with no license. On a licensed one it answers managed: true,
state, entitlements (features, a list of feature ids, and limits, where
cameras is a number, or null for no limit) and features (each feature id
and its name). An administrator also gets the holder and the lease: customer,
licenseKey (a hint, not the whole key), plan, termEnd, updatesUntil,
leaseIssuedAt, leaseExpiresAt, pendingUntil or graceUntil where they
apply, reason, installationId, keyId and cameras, the number added.
state | Meaning |
|---|---|
pending | A new install that has not reached the license server yet; it has the trial's features for up to 14 days |
trial | A trial lease from the license server |
licensed | A license key is active |
grace | The lease has run out and the server cannot be reached; the license's features continue until graceUntil |
expired | The license term has ended |
unlicensed | No license, the trial has ended, or the lease could not be renewed |
suspended, revoked | Set on the license server |
The feature ids are ai (the AI agent: event questions, Ask the recorder,
briefings, commissioning proposals), recognition (face and plate
recognition, new identities and enrolment), investigation (semantic search,
trails, new incidents and notes) and mqtt (the MQTT integration).
An administrator manages the key with POST /api/license/activate
{"licenseKey": "EVR-…", "move": false}, POST /api/license/refresh,
POST /api/license/deactivate and POST /api/license/import {"lease": …}
(a lease file issued for this installation). Each answers the new license
state. A key in use on another recorder is refused with code
no-free-seat and holders; send "move": true to take it. These routes
answer 409 on a recorder with no license.
A recorder with no internet connection is licensed by file:
GET /api/license/request (administrator) answers a license request, this
installation's id and public key signed with its own key, which the supplier
turns into a lease file for POST /api/license/import. The state then carries
offline: true, and leaseExpiresAt is when that file runs out.
A release uploaded by hand (POST /api/update/upload) must be one the license
covers: one signed on or before updatesUntil, or signed as a security fix;
an unlicensed or ended license accepts only security fixes. Anything else is a
400 whose error starts Rejected: and gives the reason.
A refusal is a 403 with a code:
license_feature, withfeature, for a route or a settings save the license does not include. Saving settings for a feature that is already on, or switching one off, is never refused.license_camera_limitwhen adding a camera (POST /api/discovery/rtsporPOST /api/discovery/{id}/adopt) would pass the licensed count. It is refused before any of the camera's sessions is used.POST /api/discovery/adopt-alllists each camera refused this way underfailed, with the sentence as itserror.
GET /api/investigation/search?mode=auto falls back to basic search when the
license does not include investigation; mode=semantic is refused.
Limits and conduct
- Live streams are capped per account; a client past the cap gets 429 with a
sentence saying so. Recorded pulls —
playback.mp4andframe.jpg— share the account's playback slots. - Anything past the 120-second review window is an extraction: it needs the account's export permission and is audited (INSTALL.md).
- A token bound to a temporary installer account cannot be minted.
- Nothing here changes a camera's configuration, and the recorder never does.
Its only writes to a camera are the audited ones above: PTZ, presets, a
camera's deterrence outputs (a relay, light or siren switched on or off when
an operator or a rule asks), and a camera's clock — its time, the recorder's
time zone and the recorder as its NTP server — which the recorder also sets
itself when
clock-syncis on.