zm-api
A modern, fast, type-safe REST API for ZoneMinder — rebuilding a twenty-year-old Perl, PHP, and CGI surface as one native service.
zm-api talks directly to an existing ZoneMinder MySQL/MariaDB database and ships in passive mode — it serves the REST API and leaves ZoneMinder’s own daemons running exactly as they were. Installing it changes nothing about how your cameras record, so it is safe to put on a live box and take back off again.
When you are ready, it takes over daemon supervision too, replacing zmdc.pl
and zmwatch.pl with one native supervisor. Passive is the on-ramp; takeover is
where it is meant to end up, and zm-api-takeover moves you either way in one
command.
One binary, one language
No PHP-FPM, no CGI, no Perl runtime to babysit. A single native executable and a systemd unit.
Live streaming built in
WebRTC and HLS from zmc's stream socket, plus recorded-event playback with byte-range seeking.
Real access control
JWT auth with separate access and refresh keys, per-feature RBAC, and row-level monitor ACLs.
Self-documenting
Every endpoint is in a generated OpenAPI 3.1 document, served live and published with each release.
A better supervisor
Takeover replaces zmdc.pl and zmwatch.pl with one native process: exponential backoff, database reconciliation, and daemon control over REST.
Safe to adopt
Passive by default, so installing changes nothing. Switch to takeover when you choose, and back again with one command.
Actually tested
1,100+ unit and integration tests with a coverage gate, run against a real ZoneMinder schema in CI.
Where to start
If you have a running ZoneMinder and want zm-api alongside it, go to Install and then Upgrading an existing ZoneMinder — the database migration step is easy to miss and fails quietly.
If you are building a client against it, start with Authentication and Permissions, then browse the API reference.
If you are deciding how to deploy the pieces together, Architecture covers what has to share a host and how to serve a dashboard.
Status
zm-api is in active development at 3.0.0-alpha. It keeps the v3 major from
ZoneMinder’s API lineage, so the URL shape is familiar, but it is not a
drop-in replacement for the CakePHP API — the response envelopes, authentication,
and error format are all different.
Install
zm-api installs as a systemd service in passive mode — it serves the REST API and leaves ZoneMinder’s daemons alone, so it is safe to put on a live box. That is the starting point; takeover is where it is meant to end up, whenever you choose to switch.
Packages
sudo dpkg -i zm-api_*.deb # Debian / Ubuntu / Raspberry Pi OS
sudo dnf install zm-api-*.rpm # Fedora / RHEL / Rocky / Alma
sudo zypper install zm-api-*.rpm # openSUSE
Arch users build from packaging/arch/PKGBUILD
with makepkg.
Installing does three things beyond copying files: it creates the zoneminder
service account if it does not exist, generates this install’s JWT signing keys
into /var/lib/zm-api/keys, and registers the systemd unit. It does not
start touching your cameras.
If the database already has ZoneMinder in it, do not start the service yet. Run the migration first — see Upgrading an existing ZoneMinder. zm-api only warns when startup migrations fail, so a database left in the wrong state gives you a service that looks healthy with features silently missing.
From source
For platforms without a package, or to test a local build.
You need current stable Rust, a MariaDB/MySQL server with a ZoneMinder schema, and the FFmpeg development libraries:
sudo apt install pkg-config libssl-dev \
libavutil-dev libavcodec-dev libavformat-dev libavfilter-dev \
libavdevice-dev libswscale-dev libswresample-dev
Then:
git clone https://github.com/SteveGilvarry/zm-api.git
cd zm-api
cargo build --release --bins
sudo ./packaging/install.sh
install.sh lays files out exactly where the packages put them, so a later
package install upgrades cleanly instead of colliding.
What gets installed
| Path | What |
|---|---|
/usr/bin/zm-api | The server |
/usr/bin/zm-api-db | Database migration tool |
/usr/bin/zm-api-takeover | Hands daemon supervision to zm-api, or back |
/etc/zm-api/base.toml | Packaged defaults — replaced on upgrade, don’t edit |
/etc/zm-api/prod.toml | Your configuration |
/etc/zm-api/zm-api.env | Environment overrides — wins over both TOML files |
/var/lib/zm-api/keys/ | JWT signing keys, generated per install |
/var/log/zm-api/ | Logs |
Man pages: zm-api(8), zm-api.env(5), zm-api-takeover(8), zm-api-db(8).
Next
First run covers starting the service and checking it works.
First run
Start it
sudo systemctl enable --now zm-api
systemctl status zm-api
journalctl -u zm-api -f
Check it responds
curl -s localhost:8080/api/v3/server/health_check
curl -s localhost:8080/api/v3/host/getVersion
Both are public — no token — which makes them useful as reverse-proxy health
checks. Then open http://localhost:8080/swagger-ui in a browser.
Get a token
curl -s -X POST localhost:8080/api/v3/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}'
You get back an access token (10 minutes) and a refresh token (1 hour). Send
Authorization: Bearer <access_token> on everything else. See
Authentication.
Two things that commonly need setting
Database. zm-api reads /etc/zm/zm.conf automatically, so on a normal
single-host install there is nothing to configure. Only set APP_DB__* in
/etc/zm-api/zm-api.env if the database is somewhere else.
CORS. If you are serving a dashboard from a different origin than the API, set this or the browser blocks every request:
APP_SERVER__ALLOWED_ORIGINS=https://zm.example.com
The failure is quiet — the dashboard loads and each request fails, reported only in the browser console. zm-api logs its effective origin list at startup and warns when it fell back to the localhost-only default, so check there first. Not needed when both sit behind one hostname; see Serving a dashboard.
If live streaming does not work
zmc’s per-monitor stream sockets are mode 0660, owned by ZoneMinder’s
ZM_STREAM_SOCKET_GROUP. If that group is not zoneminder, add it:
sudo systemctl edit zm-api
[Service]
SupplementaryGroups=<value of ZM_STREAM_SOCKET_GROUP>
The log says permission denied opening stream socket … when this is the
problem. Do not add the group to the shipped unit file directly — naming a
group that does not exist makes systemd refuse to start the service entirely.
Upgrading an existing ZoneMinder
This is the step most likely to be skipped, and it fails quietly.
zm-api runs pending migrations at startup, but if they fail it logs a warning and carries on. The result is a service that starts, answers requests, and has features silently missing — with one line in the journal to say why.
Run the migration explicitly, and read its output.
Which command
Two cases, two different commands. Getting this wrong matters.
| Your database | Command |
|---|---|
| Already has ZoneMinder in it (1.26.0+) | zm-api-db bridge -u mysql://… |
| Fresh and empty | zm-api-db up -u mysql://… |
bridge walks the embedded zm_update chain to bring the schema up to what
zm-api expects, converges triggers, stamps the baseline migration as already
applied, then runs everything after it.
up assumes it is creating the schema. Never run it against a database
that already has ZoneMinder tables — the baseline is not written to adopt an
existing schema.
Doing it
Back up first. bridge rewrites schema across many ZoneMinder versions in one
pass and there is no undo.
mysqldump --single-transaction --routines --triggers zm > zm-backup.sql
sudo systemctl stop zm-api
zm-api-db bridge -u mysql://zmuser:zmpass@localhost/zm
zm-api-db status -u mysql://zmuser:zmpass@localhost/zm # confirm
sudo systemctl start zm-api
The connection URL can also come from DATABASE_URL instead of -u.
Checking it worked
zm-api-db status -u mysql://zmuser:zmpass@localhost/zm
journalctl -u zm-api -n 50 | grep -i migrat
status lists every migration and whether it has been applied. Nothing should
be pending after a successful bridge.
Other subcommands
The underlying SeaORM CLI also accepts down, fresh, refresh, and reset.
Those exist for development and will drop data. None is part of a supported
upgrade.
Full details: man 8 zm-api-db.
How ZoneMinder, zm-api, and a dashboard fit together
Which process owns what, what has to share a host, and how a browser frontend reaches the API.
The three components
| Component | What it is | Owns |
|---|---|---|
| ZoneMinder | The existing C++/Perl/PHP install | Capture daemons (zmc, zma), the zm database schema, the events directory, the legacy web UI |
| zm-api | This project — one native binary | The REST API on port 8080, live streaming (WebRTC + HLS), event playback/VOD, retention |
| zm-web | The browser UI (separate project) — replaces ZoneMinder’s PHP web/ | Nothing server-side: static files calling this API |
zm-api does not replace ZoneMinder’s capture pipeline. In the default configuration it does not manage ZoneMinder’s processes at all; it reads and writes the same database, the same events directory, and the same shared memory that ZoneMinder itself uses, and adds an HTTP surface over them.
What must share a host
This is the constraint that shapes every deployment: zm-api must run on the
same machine as zmc.
| Resource | Path | Access | Same host? |
|---|---|---|---|
| Database | mysql://… | read-write, including DDL at startup | No — may be remote |
zm.conf | /etc/zm/zm.conf, /etc/zm/conf.d/*.conf | read-only | Same host, or copy the file |
| Events directory | /var/lib/zoneminder/events (per-event path from the Storage table) | read for playback, delete for retention | Same host, or a shared mount |
| Stream sockets | /run/zm/stream_{id}.sock | connect + read | Yes, mandatory |
| Shared memory | /dev/shm/zm.mmap.{id} | read-write | Yes, mandatory |
| PTZ sockets | /run/zm/zmcontrol-{id}.sock | connect + write | Yes (for the Perl bridge) |
Only the database is genuinely detachable. Live streaming reads zmc’s unix sockets and alarm control writes ZoneMinder’s shared memory, neither of which crosses a machine boundary.
Two consequences worth stating plainly. The stream sockets are mode 0660 owned
by ZoneMinder’s ZM_STREAM_SOCKET_GROUP, so the zm-api service user must be a
member of that group or every live stream fails with a permission error. And
zm-api runs schema migrations against the shared ZoneMinder database at
startup — see Upgrading an existing ZoneMinder
before first run on an existing install.
Passive and takeover mode
zm-api ships passive (daemon.enabled = false) so installing it cannot
disturb a running ZoneMinder. That is the on-ramp, not the destination —
takeover is where zm-api is meant to end up, and passive exists so the
switch happens on your schedule rather than at install time.
Passive. zm-api serves the REST API. zoneminder.service keeps supervising
zmdc.pl, zmc, zmfilter and the rest, exactly as before zm-api was
installed. Installing the package changes nothing about how ZoneMinder records.
The daemon-control endpoints (/api/v3/daemons*, /api/v3/system/*) are still
registered but return 503.
Takeover (daemon.enabled = true). zm-api supervises the ZoneMinder daemons
itself, replacing both zmdc.pl and zmwatch.pl with one native supervisor:
exponential backoff that resets once a daemon stays up, a reconciliation loop
that keeps running daemons in step with the Monitors table, daemon control
over the REST API, and supervision events in the journal rather than in
ZoneMinder’s own logs. The legacy zmdc.sock IPC shim stays bound, so tooling
that talks to zmdc.pl keeps working. See
Passive and takeover mode.
Exactly one supervisor may run. On startup in takeover mode zm-api runs
kill_orphan_daemons(), which pkill -9s zmc, zma, zmfilter.pl and
friends before starting its own — so leaving zoneminder.service enabled means
two supervisors killing and restarting each other’s processes. Use
zm-api-takeover, which sequences both services correctly, rather than editing
the flag by hand. See man 8 zm-api-takeover.
Serving a dashboard
zm-api can serve zm-web’s built dist/ itself ([web] enabled = true), which
removes the reverse proxy entirely — one process, one port, one certificate, and
no CORS because the UI and API share an origin. It is off by default. A proxy or
CDN in front works too. See Serving a dashboard.
Ports
| Port | What | When |
|---|---|---|
| 8080 | The entire HTTP API, HLS, and WebRTC signalling | Always (server.port) |
| 80 | ACME HTTP-01 challenge only | Only with acme.challenge = "http-01" |
| ephemeral UDP | WebRTC media | Whenever WebRTC is used |
One TCP listener, and it is HTTP or HTTPS, never both: enabling
server.tls or server.acme switches that same port to TLS. Enabling both is a
startup error. The default ACME challenge is tls-alpn-01, which opens no
second port.
The WebRTC UDP ports are OS-assigned ephemeral, with no configurable range — worth knowing before putting the media path through a firewall or NAT.
API surface
Everything lives under /api/v3, plus /swagger-ui and
/api-docs/openapi.json.
Authentication is a bearer JWT and no cookies are involved anywhere:
POST /api/v3/auth/login→{ token_type, access_token, refresh_token, expire_in }POST /api/v3/auth/refreshwith{ "token": "<refresh_token>" }GET /api/v3/auth/logout— revokes all of that user’s outstanding tokens server-sideGET /api/v3/me— returns the user plusissued_at/expires_at, so a client never has to decode the JWT
Send Authorization: Bearer <token>. Access tokens last 10 minutes and refresh
tokens 1 hour, signed with separate RSA key pairs — a leaked access key cannot
mint refresh tokens.
The one exception to the header rule is media: the snapshot route also accepts
?token=<JWT>, because <img> and <video> elements cannot set headers.
GET /api/v3/server/health_check and GET /api/v3/host/getVersion are public
and unauthenticated — useful as proxy health checks.
Two things a frontend author should know up front.
Rate limits. The authentication endpoints have their own limiter, on by
default at roughly one request per two seconds with a burst of 10 — a login
retry loop will start getting 429s. The prod profile additionally enables a
global per-IP limiter that is off in base.toml, so a dashboard that fans out
many parallel requests on page load can behave differently in production than in
development. Behind a reverse proxy, set
APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true or every client shares the
proxy’s single bucket.
Authorisation. Every non-auth route is behind feature-level RBAC, with reads
requiring View and writes requiring Edit on the relevant feature; daemon, system,
and permission-management routes require the admin-tier System feature. On top
of that, monitor and group rows are filtered per user, so two accounts can get
different results from the same endpoint.
Startup order
Nothing enforces ordering between the two services, and nothing needs to: zm-api
retries, and systemd restarts it on failure. The unit is ordered After=
mariadb/mysql but deliberately does not Requires= them, so a host using a
remote database or mysql.service still starts.
Serving a dashboard
There are three ways to do this. The first needs no reverse proxy at all.
Note that APP_STATIC_DIR, despite its name, is unrelated: it locates JWT keys
and a couple of image constants, and is never served over HTTP.
Let zm-api serve it (simplest)
zm-api can serve zm-web’s built dist/ itself:
[web]
enabled = true
root = "/usr/share/zm-web"
or in /etc/zm-api/zm-api.env:
APP_WEB__ENABLED=true
APP_WEB__ROOT=/usr/share/zm-web
One process, one port, one certificate. The UI and the API share an origin by
construction, so CORS does not apply and allowed_origins needs nothing.
TLS is already handled by [server.tls] / [server.acme].
What you get:
- SPA fallback —
/events/123servesindex.html, so a browser refresh on a client-side route works. - API paths are never shadowed.
/api/,/swagger-ui,/api-docsand/.well-known/keep their JSON 404 envelope. A mistyped endpoint still fails loudly instead of quietly returning an HTML page with status 200. - Cache headers that match how the UI is built — hashed assets are
immutablefor a year,index.htmlis alwaysno-cachebecause it names the current asset hashes. - A Content-Security-Policy on UI responses only, configurable via
web.content_security_policy(empty disables it).
Off by default: a deployment fronted by a CDN, or one already running a proxy,
should keep serving the files there. If web.enabled is true but the directory
has no index.html, zm-api logs a warning and serves the API anyway rather than
refusing to start.
Same origin behind a reverse proxy
Use this when something else already terminates TLS, or when you want a CDN, caching, or other sites on the same host.
server {
listen 443 ssl;
server_name zm.example.com;
# Dashboard: static build output. The SPA fallback lives here, because
# zm-api has none — see above.
root /var/www/zm-web;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebRTC signalling (/api/v3/live/{id}/webrtc/ws) is a WebSocket.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Media must stream, not accumulate: buffering stalls HLS and
# fragmented-MP4 playback.
proxy_buffering off;
proxy_read_timeout 3600s;
}
location /swagger-ui { proxy_pass http://127.0.0.1:8080; }
location /api-docs/ { proxy_pass http://127.0.0.1:8080; }
}
# Required for the Upgrade header above.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
The browser makes no cross-origin request here either, so CORS still does not apply.
Since the proxy is trusted, also set
APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true so rate limits key on the
real client IP rather than the proxy’s — otherwise every client shares one
bucket. Leave it false on any host where zm-api is reachable directly: the
headers are attacker-controlled there, and trusting them lets a client mint a
fresh bucket per request.
Separate origins
The dashboard on https://dash.example.com, the API on https://api.example.com.
Then CORS is in play and you must set:
APP_SERVER__ALLOWED_ORIGINS=https://dash.example.com
Unset, zm-api allows localhost only. The failure mode is quiet — the dashboard loads and every request fails, reported only in the browser console — so this is the single most likely day-one problem. zm-api logs its effective origin list at startup and warns when it fell back to the default; check there first.
A bare * is not accepted. The API sends credentials, and the CORS spec forbids
that pairing. Entries are exact origins, or scheme://host:* to match any port
on a host (intended for development).
See also Permissions for what a client can discover about its own account, and Architecture for the ports involved.
Configuration
Sources apply in order, each overriding the previous:
- ZoneMinder’s
/etc/zm/zm.confand/etc/zm/conf.d/*.conf— database settings only. This is what lets a packaged install work against an existing ZoneMinder without being told anything. /etc/zm-api/base.toml— the packaged defaults layer. Replaced on upgrade; don’t edit it./etc/zm-api/prod.toml— your configuration.APP_*environment variables, from/etc/zm-api/zm-api.env.
The environment file wins over everything. That makes it the right place for host-specific settings, and a trap: a stale value there silently overrides a corrected default shipped in a later package upgrade.
Naming
APP_, then the TOML path in upper case with __ between levels:
| TOML | Environment |
|---|---|
db.host | APP_DB__HOST |
server.allowed_origins | APP_SERVER__ALLOWED_ORIGINS |
server.middleware.body_limit_bytes | APP_SERVER__MIDDLEWARE__BODY_LIMIT_BYTES |
List values are indexed: APP_SERVER__ACME__DOMAINS__0=api.example.com.
Restart the service after editing.
The settings you are most likely to need
| Variable | Notes |
|---|---|
APP_SERVER__ALLOWED_ORIGINS | CORS. Required for a cross-origin dashboard — see Serving a dashboard |
APP_DB__HOST etc. | Only if the database isn’t where zm.conf says |
APP_DAEMON__ENABLED | false = passive (install default), true = zm-api supervises the daemons. Prefer zm-api-takeover over setting this by hand |
APP_SERVER__PORT | Default 8080 |
RUST_LOG | info, or per-module: info,zm-api::streaming=debug |
APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS | true only behind a trusted proxy |
man 5 zm-api.env documents every variable.
Profiles
APP_PROFILE selects which TOML loads alongside base.toml: dev, test,
test-db, or prod. Packaged installs use prod.
prod differs from the defaults in ways worth knowing: it enables the global
per-IP rate limiter (off in base.toml) and trusts proxy headers.
Secrets
JWT signing keys are generated per install into /var/lib/zm-api/keys and are
never packaged. To regenerate:
sudo /usr/share/zm-api/setup-instance.sh # idempotent; won't overwrite
Existing tokens stop working if you delete the old keys first.
TLS and ACME
The API server can terminate TLS directly. The secret.*_key files are used for
JWT signing, not for TLS certificates.
Enable TLS in the API
- Set
server.portto443(or another HTTPS port). - Enable TLS in config:
server.tls.enabled = trueserver.tls.cert_path = "/etc/letsencrypt/live/example.com/fullchain.pem"server.tls.key_path = "/etc/letsencrypt/live/example.com/privkey.pem"
- Restart the service.
Environment variable equivalents:
APP_SERVER__TLS__ENABLED=trueAPP_SERVER__TLS__CERT_PATH=/etc/letsencrypt/live/example.com/fullchain.pemAPP_SERVER__TLS__KEY_PATH=/etc/letsencrypt/live/example.com/privkey.pem
Built-in ACMEv2 (rustls-acme)
rustls-acme (built on instant-acme) can request and renew certs without
external tooling.
Example config:
server.acme.enabled = trueserver.acme.domains = ["api.example.com"]server.acme.contact_emails = ["ops@example.com"]server.acme.cache_dir = "/var/lib/zm-api/acme"server.acme.production = trueserver.acme.challenge = "tls-alpn-01"(default)
If you need HTTP-01 (behind a TLS-terminating proxy), set:
server.acme.challenge = "http-01"server.acme.http_port = 80
Environment variable equivalents:
APP_SERVER__ACME__ENABLED=trueAPP_SERVER__ACME__DOMAINS__0=api.example.comAPP_SERVER__ACME__CONTACT_EMAILS__0=ops@example.comAPP_SERVER__ACME__CACHE_DIR=/var/lib/zm-api/acmeAPP_SERVER__ACME__PRODUCTION=trueAPP_SERVER__ACME__CHALLENGE=tls-alpn-01
External ACMEv2 automation (Let’s Encrypt)
certbot (standalone)
Use a pre/post hook so certbot can bind port 80/443 during issuance:
certbot certonly --standalone -d api.example.com \
--agree-tos -m ops@example.com \
--pre-hook "systemctl stop zm-api" \
--post-hook "systemctl start zm-api"
Renewals are handled by the certbot timer:
systemctl enable --now certbot.timer
lego (standalone)
lego --email ops@example.com --domains api.example.com \
--path /etc/letsencrypt --accept-tos run
On renew, restart the API so it reloads the new certs:
systemctl restart zm-api
Notes
- Ensure ports 80/443 are reachable from the internet.
- Static TLS (
server.tls.enabled) loads certs at startup; restart after renewals. - Built-in ACME keeps certs fresh without restarts.
server.acme.enabledandserver.tls.enabledare mutually exclusive.- HTTP-01 mode starts a separate listener on
server.acme.http_portfor challenges. server.acme.cache_dirmust be writable by the service user.- The systemd unit runs as
User=zoneminder/Group=zoneminderwithSupplementaryGroups=video ssl-cert. Ensure the cert/key are readable by thessl-certgroup — for examplesetfacl -m g:ssl-cert:rx /etc/letsencrypt/{live,archive}— or chown them tozoneminderif you prefer fixed ownership.
Passive and takeover mode
zm-api ships passive so that installing it cannot disturb a running ZoneMinder. That is a starting point, not a destination: takeover is where zm-api is meant to end up. Passive exists so you choose when to switch, on your own schedule, with a one-command way back.
Passive
daemon.enabled = false. zm-api serves the REST API. zoneminder.service keeps
supervising zmdc.pl, zmc, zmfilter and the rest, exactly as before zm-api
was installed. Installing the package changes nothing about how ZoneMinder
records, which is what makes it safe to drop onto a live box.
The daemon-control endpoints (/api/v3/daemons*, /api/v3/system/*) are still
registered but return 503.
Takeover
daemon.enabled = true. zm-api supervises the ZoneMinder daemons itself,
replacing both zmdc.pl and zmwatch.pl with one native supervisor.
This is an upgrade, not a lateral move. What you gain:
- One supervisor instead of two processes. ZoneMinder runs
zmdc.plto start daemons andzmwatch.plto poll whether the capture daemons are still healthy. zm-api does both in its own health-check loop, so there is no second Perl daemon whose own death goes unnoticed. - Exponential backoff with recovery. A daemon that crashes repeatedly backs off between restarts rather than being restarted at a fixed interval forever, and the backoff resets once it has stayed up. A camera that is genuinely unreachable stops generating restart churn.
- Reconciliation against the database. A loop syncs running daemons with
what the
Monitorstable says should be running, so enabling or disabling a monitor through the API takes effect without a separate restart cycle. - Daemon control over the REST API. Start, stop, and query daemons through
/api/v3/daemons*instead of shelling out tozmdc.pl. - Structured logging. Supervision events go to the journal alongside everything else zm-api logs, rather than into ZoneMinder’s own log tables and files.
The legacy zmdc.sock IPC shim is still bound, so tooling that talks to
zmdc.pl keeps working.
Exactly one supervisor may run. On startup in takeover mode zm-api runs
kill_orphan_daemons(), which pkill -9s zmc, zma, zmfilter.pl and
friends before starting its own. Leaving zoneminder.service enabled means two
supervisors killing and restarting each other’s processes: daemons that restart
in a loop and events that record erratically. zm-api-takeover handles the
ordering for you — that is the whole reason it exists.
Switching
sudo zm-api-takeover # take over
sudo zm-api-takeover --revert # hand back
The script sequences both services correctly — disable and stop
zoneminder.service, flip the flag, restart zm-api; and the reverse on the way
back. Prefer it over editing APP_DAEMON__ENABLED by hand.
Before you take over
- zm-api must already be working in passive mode. Takeover is not a way to fix a broken install: if the API cannot reach the database, taking over the capture daemons as well means nothing records.
- The service user needs write access to the events directory, and membership
of
ZM_STREAM_SOCKET_GROUPfor live streaming. - Nothing else may be starting the ZoneMinder daemons — no cron entry, no
zmpkg.plinvocation, no second host sharing the database with the same server ID.
Verifying
systemctl is-active zm-api # active
systemctl is-enabled zoneminder # disabled (or not-found)
ps -o ppid=,cmd= -C zmc # one zmc per enabled monitor,
# parented by the zm-api process
journalctl -u zm-api -n 50
Then confirm recording actually continues — watch the event count for an active
monitor rise over a few minutes. A clean systemctl status proves the
supervisor started, not that video is being captured.
Reverting
sudo zm-api-takeover --revert is the first thing to try if capture misbehaves.
It restores the arrangement the host had before, and the REST API keeps working
throughout — passive is a fully supported mode, not a broken state.
Reverting is cheap and reversible in both directions, which is the point: you can take over on a Tuesday afternoon, watch it for a day, and go back with one command if anything looks wrong. Treat it as a rollback, not a defeat.
Full details: man 8 zm-api-takeover.
Recording retention
Automatic cleanup that deletes whole events — media and database rows —
oldest first, per Storage, when a limit is breached. It replaces ZoneMinder’s
PurgeWhenFull filter.
Off by default. Enable it in prod.toml:
[retention]
enabled = true
What it will not delete
- Archived events
- Events still being recorded
- The newest event for each monitor
That last one means a monitor never ends up with no footage at all, however tight the limits.
Limits
Configured per Storage. Any breach triggers reaping:
| Limit | Meaning |
|---|---|
| Free-space floor | Keep at least this much free on the volume |
| Age | Delete events older than this |
| Quota | Cap total bytes for this Storage |
Media deletion
The reaper and the DELETE /api/v3/events/{id} endpoint share one code path,
so both remove the same things: the event’s directory on disk plus its Frames,
Events_Hour/Day/Week/Month, and Events_Archived rows, transactionally.
No foreign key cascade covers those, which is why it is centralised.
On-disk paths honour the per-event Storage row and ZoneMinder’s Deep, Medium,
and Shallow layout schemes.
Watching it
journalctl -u zm-api -f | grep -i reap
Every deletion is logged with the event id and the limit that triggered it. Start with generous limits and read the log for a cycle or two before tightening them — deletion is not reversible.
Replacing the Perl maintenance daemons
ZoneMinder runs three periodic housekeeping daemons that zm-api can take over:
zmstats.pl, zmaudit.pl and zmtelemetry.pl. Each is independently
switchable and all three default to off, so an existing install keeps
running the Perl until you move over deliberately.
Enable the Rust job and disable the matching Perl daemon together. Running both has them competing over the same rows. In takeover mode zm-api supervises the Perl daemons, so removing one from ZoneMinder’s set is the other half of the switch.
Stats — replaces zmstats.pl
[maintenance.stats]
enabled = true
interval_seconds = 300
Six jobs on one timer, none of which touch event media or Events rows:
- samples CPU and memory into
Server_Stats, and trims it to a day - mirrors the same sample onto this host’s
Serversrow (multi-server only) - evicts
Monitor_Statusrows whose heartbeat has stopped - ages events out of the
Events_Hour/Day/Week/Monthwindows and resyncs the counters they feed - prunes
LogsunderZM_LOG_DATABASE_LIMITandZM_LOG_AUDIT_DATABASE_LIMIT - prunes expired
SessionsunderZM_COOKIE_LIFETIME
It reads those retention settings from ZoneMinder’s own Config table, so the
values you already set still apply. One difference: ZoneMinder splices those
values straight into its SQL, whereas here they are parsed first — a limit that
is neither a row count nor a recognised interval disables that pruning and logs
why, rather than producing a broken statement.
Audit — replaces zmaudit.pl
[maintenance.audit]
enabled = true
dry_run = true # leave this on for a pass or two first
min_age_seconds = 3600
Four checks:
- Orphaned
FramesandStatswhose event is gone. Pure garbage; nothing can reach it andFramesis usually the largest table in the database. - Empty events that never recorded a frame and are older than
min_age_seconds. - Unclosed events left by a capture daemon that died mid-recording. End
time, length, frame count and score totals are recomputed from the frames
that did land, and the event is marked
Recovered.so the repair is visible. An update, never a delete. - Counter drift in
Event_SummariesandStorage.DiskSpace, recomputed from the rows they summarise.
Two deliberate differences from zmaudit.pl
Archived events are genuinely skipped. zmaudit intends to skip them when
deleting frameless events, but the column it tests is not in its SELECT list,
so the guard never fires and it deletes them. Archiving an event is a user
saying keep this.
dry_run means dry. zmaudit’s --report suppresses its deletes but still
performs row updates, empty-directory removal, stray-image unlinking, log
pruning and counter resyncs — so “just report” is not what it does. Here
nothing is written at all.
The filesystem half
[maintenance.audit.filesystem]
enabled = true
Reconciles event directories against Events rows. Off even when the audit is
on, because it is the only part that touches the filesystem.
It does not work the way zmaudit.pl does, deliberately. zmaudit computes an
event’s directory from its StartDateTime and rm -rfs the result. That
computation can be wrong — from a timezone difference between the recording
daemon and the auditor, a corrupted timestamp, a Scheme changed after the
event was recorded, or a wrong StorageId — and when it is wrong, the thing
removed is an unrelated directory. Nothing reports an error.
Here no destructive action ever acts on a computed path. Directories are
found by walking, identified from evidence inside them (a {id}-video.mp4, a
.{id} marker, frame stills, or a numeric leaf name where the scheme allows
it), and only a path that was actually enumerated is ever moved. A directory
nothing identifies is reported and left alone — zmaudit reconstructs a timestamp
from the path and deletes it.
Four further safeguards:
Orphans are moved, not deleted. They go to .zm-api-quarantine/{stamp}/ in
the same storage, which is an atomic rename within one filesystem. You have
quarantine_retention_days (7 by default) to look at what was taken before a
later sweep removes it.
Preconditions. The storage path must exist, be a directory, and contain at least one monitor directory. A volume that failed to mount presents an empty directory, which otherwise looks exactly like every event being orphaned — that pass refuses and says so.
Two passes. An orphan must be seen in confirmations_required consecutive
passes before anything happens, so a row committed after a walk began is never
mistaken for one.
A derivation canary. For events found by both routes, the computed path is compared against the real one. Sustained disagreement disables the filesystem half and reports why — this is what turns the timezone class of bug from an invisible data-loss event into an alarm.
Deleting Events rows whose media is missing is separate and off by default
(remove_rows_without_media), because the evidence there is an absence rather
than a presence.
Telemetry — replaces zmtelemetry.pl
[maintenance.telemetry]
enabled = true
interval_seconds = 1209600 # 14 days
Off unless explicitly enabled, and it stays off.
No geolocation lookup. zmtelemetry.pl calls ipinfo.io on every
collection — including when you only asked to preview the payload — and reports
city, region, country and latitude/longitude. That discloses the server’s public
IP to a third party under the heading of anonymous statistics. Those fields are
still sent so the receiving end sees the shape it expects, but they are always
Unknown.
Camera paths are scrubbed before they leave the machine: credentials and hostname are replaced, keeping only the scheme and path shape. A remote path that is not a parseable URL is dropped entirely rather than forwarded.
The interval comes from ZM_TELEMETRY_INTERVAL, which ZoneMinder ships as the
Perl expression 14*24*60*60 and evaluates as code. Here it is parsed as a
number or a product of numbers; anything else falls back to the configured value
and logs a warning.
Watching it
journalctl -u zm-api -f | grep -iE 'audit|stats|telemetry'
The audit logs a line per pass summarising what it found, and says explicitly when it is in dry-run mode.
Authentication
zm-api uses bearer JWTs. No cookies are involved anywhere.
Getting tokens
POST /api/v3/auth/login
Content-Type: application/json
{ "username": "admin", "password": "…" }
{
"token_type": "Bearer",
"access_token": "eyJ…",
"refresh_token": "eyJ…",
"expire_in": 600
}
Send the access token on every other request:
Authorization: Bearer eyJ…
Lifetimes and refresh
Access tokens last 10 minutes, refresh tokens 1 hour. They are signed
with separate RSA key pairs, deliberately — a leaked access key cannot mint
refresh tokens. Tokens also carry a typ claim, so a refresh token cannot be
presented as an access token or vice versa.
POST /api/v3/auth/refresh
Content-Type: application/json
{ "token": "<refresh_token>" }
Knowing when a token expires
Don’t decode the JWT client-side. GET /api/v3/me returns the user plus
issued_at, expires_at, and token_type:
{
"user": { "username": "operator", "system": "None", "monitors": "Edit", … },
"token_type": "access",
"issued_at": 1755820000,
"expires_at": 1755820600
}
Logging out
GET /api/v3/auth/logout
Authorization: Bearer <access_token>
This is a real server-side revocation, not a client-side token discard: it
raises the user’s TokenMinExpiry floor, so every outstanding token for
that account stops working immediately, including ones issued to other devices.
Media URLs
<img> and <video> elements cannot set headers, so the snapshot route also
accepts the token as a query parameter:
GET /api/v3/monitors/1/snapshot?token=<JWT>
This is the only place that is accepted. It puts a credential in a URL, where it can land in proxy logs and browser history — prefer the header wherever the client controls the request.
Rate limiting
The authentication endpoints have their own limiter, on by default at roughly
one request per two seconds with a burst of 10. A login retry loop will start
getting 429s. The prod profile additionally enables a global per-IP limiter
that is off in base.toml, so a client that fans out many parallel requests on
page load can behave differently in production than in development.
Behind a reverse proxy, set APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true
or every client shares the proxy’s single bucket. Leave it false anywhere
zm-api is reachable directly — the headers are attacker-controlled there.
Permissions
Two independent layers apply to every request: feature-level RBAC decides whether you may touch a kind of resource at all, and row-level ACLs decide which specific monitors and groups you see.
Feature-level RBAC
ZoneMinder accounts carry eight permission columns. zm-api enforces all of them,
deriving the required level from the HTTP method — reads need View, writes
need Edit.
| Feature | Gates |
|---|---|
Stream | Live video (WebRTC, HLS, snapshots) |
Events | Event list, playback, frames, filters, tags, search |
Control | PTZ, control presets, X10 triggers |
Monitors | Monitors, zones, monitor presets, ONVIF discovery |
Groups | Groups and group membership |
Devices | Devices, manufacturers, models |
Snapshots | Snapshots |
System | Config, logs, storage, users, servers, reports, daemon control, AI registry |
Stream has no Edit tier in ZoneMinder — View is the maximum.
Granting permissions is deliberately System-tier, not the tier of the thing
being granted, so a Groups:Edit user cannot grant themselves more.
Discovering your own permissions
GET /api/v3/me returns all eight columns and is not feature-gated, so it
works for any authenticated account including System: None.
This matters. The CakePHP API had no way to ask “what am I allowed to do” — a
client had to fetch /users.json and find its own row, which was itself gated
on System != 'None'. An ordinary operator got a 401 and could infer only that
one column. System: None with Monitors: Edit is a perfectly legal
ZoneMinder account, and such a user could not discover its own monitor
permissions at all.
So gate your UI on /me:
{
"user": {
"username": "operator",
"system": "None",
"stream": "View",
"events": "View",
"control": "None",
"monitors": "Edit",
"groups": "View",
"devices": "None",
"snapshots": "View"
}
}
Row-level ACLs
On top of the feature check, Monitors_Permissions and Groups_Permissions
filter individual rows per user. Two accounts with identical feature
permissions can get different results from the same endpoint.
This is default-allow for backward compatibility: a user with no explicit row-level entries sees everything their feature permissions allow.
For routes naming a monitor in the path — PTZ, live streaming — the row-level guard runs inside the feature check, so a caller without the feature is refused before any database query happens.
What a denial looks like
| Status | Meaning |
|---|---|
401 | No token, expired token, or a revoked one |
403 | Authenticated, but the account lacks the feature or level |
404 | May exist, but row-level ACLs hide it from this account |
The 404 is deliberate — a 403 would confirm the resource exists.
Live streaming
Two delivery paths from one API, both sourced from zmc’s per-monitor stream socket — a single unix socket carrying video and audio with a HELLO codec handshake.
Everything under /api/v3/live/{monitor_id} requires Stream: View and passes
the row-level monitor ACL.
HLS
Works in any HTML5 <video> element, including Safari natively.
GET /api/v3/live/{monitor_id}/hls/master.m3u8
GET /api/v3/live/{monitor_id}/hls/live.m3u8
GET /api/v3/live/{monitor_id}/hls/init.mp4
GET /api/v3/live/{monitor_id}/hls/{segment}
Fragmented MP4, with low-latency HLS available. Higher latency than WebRTC but far easier to proxy and cache.
WebRTC
Lower latency, at the cost of a signalling handshake and UDP.
GET /api/v3/live/{monitor_id}/webrtc/ws (WebSocket)
The media itself travels over OS-assigned ephemeral UDP ports — there is no configurable range, which matters before putting the media path through a firewall or NAT.
For viewers behind symmetric NAT, STUN is not enough and you need a TURN server:
[streaming.webrtc.turn]
enabled = true
server = "turn:turn.example.com:3478"
username = "zm"
password = "…"
Media is relayed through TURN, so size it accordingly.
Session control and snapshots
POST /api/v3/live/{monitor_id}/start
DELETE /api/v3/live/{monitor_id}/stop
GET /api/v3/live/{monitor_id}/stats
GET /api/v3/live/sessions
GET /api/v3/live/sources
GET /api/v3/monitors/{monitor_id}/snapshot
The snapshot route accepts ?token=<JWT> because <img> cannot set headers.
Still images are rotated for you
/events/{id}/thumbnail and /monitors/{id}/snapshot apply the monitor’s
Orientation before returning the JPEG, matching what ZoneMinder’s own image
view serves. A client can size and render the result directly.
This is deliberately stills only. Rotating live video would mean re-encoding the stream; a client can do it in CSS for nothing, so WebRTC and HLS hand back the camera’s own frames.
ROTATE_0 — almost every camera — still streams the bytes straight off disk
with no decode.
Recorded playback
GET /api/v3/events/{id}/video
GET /api/v3/events/{id}/stream/video.mp4
GET /api/v3/events/{id}/stream/playlist.m3u8
GET /api/v3/events/{id}/thumbnail
These need Events: View, not Stream.
Behind a reverse proxy
Streaming routes are deliberately excluded from zm-api’s own compression layer,
and they must not be buffered either. With proxy_buffering on an HLS or MP4
route stalls. See Serving a dashboard for a working nginx
config.
When streams fail
permission denied opening stream socket … in the log means the service user
is not in ZoneMinder’s ZM_STREAM_SOCKET_GROUP. The sockets are mode 0660. Fix
with a systemd drop-in — see Troubleshooting.
API reference
Every endpoint is generated from the running server’s own OpenAPI 3.1 document,
so it cannot drift from the code. The same document is served live at
/api-docs/openapi.json on any zm-api instance and is attached to every GitHub
release.
Open the full reference ↗ Download openapi.json
The explorer below is the same page, embedded. It reads better full-screen.
Using the spec directly
openapi.json is a plain OpenAPI 3.1 document — feed it to any generator:
# From a running instance
curl -s localhost:8080/api-docs/openapi.json > openapi.json
# Or from the binary, without starting a server
zm-api --openapi > openapi.json
Because --openapi needs no database and no configuration, it is also the way
to diff the API surface between two releases:
diff <(zm-api-3.0.0-alpha.1 --openapi | jq -S .) \
<(zm-api-3.0.0-alpha.2 --openapi | jq -S .)
Conventions
Details are on their own pages, but in short:
- Everything lives under
/api/v3. - Authentication is
Authorization: Bearer <jwt>; see Authentication. - Reads need
Viewon the relevant feature, writes needEdit; see Permissions. - List endpoints take
pageandpage_sizeand return{ items, total, per_page, current_page, last_page }. - Errors return
{ kind, error_message, code, details }—kindis a stable string such asINVALID_INPUT_ERRORorNOT_FOUND_ERROR, anddetailsnames the offending fields.
Command-line tools
Three binaries, each with a man page.
zm-api
The server. It takes no arguments beyond these — everything else is configuration.
| Option | What |
|---|---|
-h, --help | Usage summary |
-V, --version | Version |
--openapi | Write the OpenAPI 3.1 spec to stdout |
All three are handled before configuration loads, so they still answer on a host whose config is broken.
zm-api --openapi > openapi.json # diff the API between releases
zm-api --openapi | jq '.paths | keys' # list every route
man 8 zm-api
zm-api-db
Database migrations. Built by cargo as migrator; installed as zm-api-db.
| Command | When |
|---|---|
bridge -u <url> | Existing ZoneMinder database (1.26.0+) |
up -u <url> | Fresh, empty database only |
status -u <url> | List migrations and whether each applied. Read-only |
The URL can come from DATABASE_URL instead of -u. See
Upgrading — picking the wrong one of
bridge/up matters.
man 8 zm-api-db
zm-api-takeover
Switches the host between passive and takeover mode, sequencing
zoneminder.service and zm-api.service so they are never both supervising.
| Option | What |
|---|---|
| (none) | Take over |
--revert, --passive | Hand control back to ZoneMinder |
--yes, -y | Skip the confirmation prompt |
Requires root. See Passive and takeover mode.
man 8 zm-api-takeover
Configuration reference
man 5 zm-api.env documents every APP_* variable.
Troubleshooting
The dashboard loads but every request fails
CORS. The browser reports it in its own console and the API logs nothing unusual, which is what makes it hard to spot.
Set the dashboard’s origin:
APP_SERVER__ALLOWED_ORIGINS=https://zm.example.com
zm-api logs its effective origin list at startup and warns when it fell back to the localhost-only default — check there first:
journalctl -u zm-api | grep -i cors
A bare * is not accepted. The API sends credentials and the CORS spec forbids
that pairing. Not needed at all when the dashboard and API share a hostname —
see Serving a dashboard.
permission denied opening stream socket
The service user is not in ZoneMinder’s ZM_STREAM_SOCKET_GROUP. The
per-monitor sockets are mode 0660.
sudo systemctl edit zm-api
[Service]
SupplementaryGroups=<value of ZM_STREAM_SOCKET_GROUP>
Do not add it to the shipped unit file. A group that does not exist makes
systemd refuse to start the service entirely (status=216/GROUP).
The service starts but features are missing
Migrations probably failed. zm-api only warns on migration failure at startup, so the service looks healthy.
journalctl -u zm-api | grep -i migrat
zm-api-db status -u mysql://zmuser:zmpass@localhost/zm
See Upgrading.
The unit won’t start at all
systemctl status zm-api
journalctl -u zm-api -n 100 --no-pager
Common causes:
| Symptom | Cause |
|---|---|
status=216/GROUP | A SupplementaryGroups= entry names a group that doesn’t exist |
| Cannot connect to database | APP_DB__* set to something wrong, overriding the working zm.conf fallback |
| Missing JWT keys | /var/lib/zm-api/keys not provisioned — run /usr/share/zm-api/setup-instance.sh |
| Port in use | Something else on 8080; set APP_SERVER__PORT |
Streams stall behind a reverse proxy
proxy_buffering off; on the live and playback routes, and the
Upgrade/Connection headers with proxy_http_version 1.1 for the WebRTC
signalling WebSocket. Full config in Serving a dashboard.
WebRTC connects then shows nothing
Usually NAT. STUN cannot traverse symmetric NAT; configure a TURN server. Note the media ports are OS-assigned ephemeral UDP with no configurable range. See Live streaming.
Getting 429s
Rate limiting. The auth endpoints allow roughly one request per two seconds
with a burst of 10; prod also enables a global per-IP limiter.
Behind a proxy, set APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true — without
it every client shares the proxy’s single bucket.
Turning up the logs
sudo systemctl edit zm-api # [Service] Environment=RUST_LOG=debug
# or per-module:
# RUST_LOG=info,zm-api::streaming=debug
# RUST_LOG=info,zm-api::daemon=debug
sudo systemctl restart zm-api
journalctl -u zm-api -f
Changelog
All notable changes to zm-api are recorded here. The format follows
Keep a Changelog, and versioning is
SemVer — carrying the v3 major from ZoneMinder’s API
lineage, so a client written against the legacy /api/v3 surface has a
recognisable path forward.
[Unreleased]
Added
-
Reports.CreatedByis read and written (#29). The column has existed since 1.37 but was never modelled, so it was neither stored nor returned. Attribution comes from the authenticated token rather than the request body — letting a client name the creator is forging authorship. (description, the other half of that issue, is not possible: there is no such column.) -
Native replacements for three Perl maintenance daemons —
zmstats.pl,zmaudit.pl(database side) andzmtelemetry.pl— each independently switchable under[maintenance]and all off by default, so an existing install keeps running the Perl until the operator moves over. Enable the Rust job and disable the matching daemon together; running both has them competing over the same rows.
Stats samples CPU and memory intoServer_Stats, evicts staleMonitor_Statusheartbeats, ages events out of theEvents_Hour/Day/Week/Monthwindows and resyncs the counters they feed, and prunesLogsandSessionsunder ZoneMinder’s own retention settings.
Audit removesFrames/Statsrows whose event is gone, deletes events that never recorded a frame, closes events left open by a capture daemon that died, and recomputesEvent_SummariesandStorage.DiskSpacefrom the rows they summarise.
Telemetry posts the same anonymous report on the same schedule.
Three deliberate differences from the Perl, each because the original is wrong rather than because this is simpler: archived events are genuinely skipped when deleting frameless events (zmaudit tests a column it never selects, so its guard never fires);dry_runwrites nothing at all (zmaudit’s--reportstill performs updates, log pruning and counter resyncs); and telemetry performs no geolocation lookup — zmtelemetry callsipinfo.ioon every collection, disclosing the server’s public IP to a third party under the heading of anonymous statistics. Those fields are still sent, always"Unknown".
Retention limits and the telemetry interval are read from ZoneMinder’sConfigtable but parsed rather than interpolated: the Perl splicesZM_LOG_DATABASE_LIMITstraight into SQL andevalsZM_TELEMETRY_INTERVALas code, so aConfigrow is an injection surface in both.
The filesystem half reconciles event directories againstEventsrows, and is off even when the audit is on. It deliberately does not work the wayzmaudit.pldoes: zmaudit computes a directory fromStartDateTimeandrm -rfs the result, so a timezone difference, corrupted timestamp, changedSchemeor wrongStorageIdmakes it remove an unrelated directory with no error. Here nothing destructive acts on a computed path — directories are found by walking, identified from evidence inside them, and only an enumerated path is ever moved. A directory nothing identifies is reported and left alone.
Orphans are moved to a quarantine directory rather than deleted (an atomic rename within the storage, swept afterquarantine_retention_days); a pass refuses if the storage is missing or has no monitor directories, so an unmounted volume cannot orphan the whole database; an orphan must be seen in two consecutive passes before anything happens; and a derivation canary compares computed against actual paths for events found by both routes, disabling the filesystem half on sustained disagreement. That last one turns the timezone class of bug from silent data loss into an alarm. -
zm-api can serve the zm-web browser UI itself (
[web] enabled = true,APP_WEB__ENABLED). One process instead of a reverse proxy in front of two: the UI and the API share an origin by construction, so CORS stops applying, and TLS is already handled by[server.tls]/[server.acme]. Includes the SPA fallback so a refresh on/events/123works,immutablecaching for hashed assets withno-cacheonindex.html, and a configurable Content-Security-Policy applied to UI responses only.
API paths keep their JSON 404 envelope — the SPA fallback never shadows/api/,/swagger-ui,/api-docsor/.well-known/, so a mistyped endpoint still fails loudly instead of returning an HTML page with status 200.
Off by default; enabling it with noindex.htmlpresent logs a warning and serves the API anyway rather than refusing to start. -
Still images are rotated to match the monitor’s
Orientation./events/{id}/thumbnailand/monitors/{id}/snapshotnow return an upright JPEG, as ZoneMinder’s own image view does — previously aROTATE_90camera produced sideways thumbnails, and the failure was silent because nothing errored. Stills only: rotating live video would mean re-encoding, and a client can do it in CSS for free.ROTATE_0keeps the existing zero-copy path. -
Man pages:
zm-api(8),zm-api.env(5),zm-api-takeover(8),zm-api-db(8). -
zm-api --help,--version, and--openapi(writes the OpenAPI spec to stdout, so the API surface can be diffed or fed to a client generator without running a server). All three work before configuration is loaded, so they still answer on a host whose config is broken. -
The migration tool ships as
/usr/bin/zm-api-dbin all three packages. It was previously built but packaged nowhere, leaving no upgrade path for an existing ZoneMinder database. -
server.allowed_origins(APP_SERVER__ALLOWED_ORIGINS) as a documented,APP_-prefixed setting, accepting a TOML array or a comma-separated string. -
A documentation site (mdBook) published to GitHub Pages, covering install, configuration, deployment architecture, TLS, passive/takeover mode, and permissions — plus a browsable API reference rendered from the OpenAPI spec, which CI exports from the freshly built binary so it cannot drift from the code.
docs/is now the contributor-facing plan tree only. -
The OpenAPI spec is exported in CI and attached to each release, alongside a
SHA256SUMSfile covering every artifact. -
Release notes are taken from this file’s entry for the tag, falling back to GitHub’s generated commit list only when there isn’t one.
-
scripts/check-version-consistency.sh, run in CI before any package is built, so a half-finished version bump fails fast. -
openapi.jsonis committed as a reviewed baseline, and CI fails a pull request whose spec change could break a deployed client — a removed endpoint, a replaced response shape, a response field no longer guaranteed, a dropped enum value, or a request that gained a required field. New endpoints and new optional fields pass with a notice. This is the guard that would have caught the/mechange above in the pull request that made it.
Fixed
- The rate limiter made the API unusable for any browser client (#70). A
burst of
0with the limiter enabled was clamped silently to1, so one request succeeded and everything after it returned 429 — no page in any single-page app could load, and nothing said why. A burst that small is never intentional, so it is now treated as the misconfiguration it is: the server substitutes a usable value and logs an error naming the setting.
The setting is renamedrate_limit_period_secs, becauserate_limit_per_secondread as a rate while meaning a period — setting it to4expecting four requests a second gave one request every four seconds. The old name is still accepted, so existing configuration keeps working.
The production defaults are retuned from one token per 25 seconds with a burst of 50 to one per second with a burst of 120. The old values let the first screen through and then throttled everything after it to one request every 25 seconds. Credential brute-forcing is handled separately and far more tightly by the auth limiter, which is why the global one can afford to be generous. - Six duplicate
operationIds (#32) — the five AI-model routes collided with the camera-model routes, and two unrelatedupdate_statehandlers with each other. A generator silently emits one method and drops the other. - Four routes that enforce authentication did not say so in the spec (#32):
/daemons,/daemons/{id},/system/statusand the WebRTC signalling socket. A generated client reads a missingsecuritykey as “no token needed”. NaiveDateTimeWrapperclaimedformat: date-time(#32) while emitting2025-04-24T12:34:56, which has no offset and is therefore not RFC 3339. Generators built an offset-aware parser that rejected every value the API sends. It is now described as what it is — local wall-clock time as ZoneMinder stores it.- Zone
Areawas never computed (#43). Every zone created through the API hadArea = 0hardcoded, and changing a zone’s coordinates never recomputed it. That is not cosmetic: when a zone’s units arePercent, the alarm thresholds are stored relative toArea, so those zones had thresholds that silently did not mean what they said. Area is now derived fromCoordson both create and update, matching ZoneMinder’s owngetPolyArea(plain shoelace — upstream keeps an inclusive-pixel variant but no longer calls it). Coordinates that do not describe a polygon are rejected with a 400 rather than stored with a zero. SaveJPEGsrejected its own default (#39). It is a two-bit mask whose column default is 3, but the bound was-1..=1, so the API refused the value ZoneMinder ships with — the same class of bug as #19. The two neighbouring fields had the same copy-pasted bound and were also wrong:VideoWriteris 0–2 (disabled / encode / camera passthrough) andRecordAudiois 0–1. All three confirmed against the upstream monitor form rather than inferred.- Storage created through the API could never be reclaimed (#44).
DoDeletewas hardcoded to 0 while the column defaults to 1, so neither the retention reaper norDELETE /events/{id}could remove media from a storage the API created — the disk fills and nothing says why. Now defaults to 1, matching the column, and is settable on create. - Over-long values return 400 instead of 500 (#55). Roughly forty request
fields write to fixed-width columns with no length rule of their own, and each
turned an over-long value into
DATABASE_ERRORwith no indication of what was wrong. A “data too long” error is now mapped toVALUE_TOO_LONG/ 400 naming the offending column. Only the column name is surfaced — the driver’s message can carry the rejected value and surrounding SQL, and that redaction is tested. Per-DTO rules remain better where they exist, since they reject before the round trip; this is the net under everything else. - Bridged installs kept a legacy collation (#40), failing upgrade-parity on
every pull request since #14. The bridge normalised
EncoderTemplatestoutf8mb4_unicode_ci, which is the value the legacy chain creates it with — converting toward the old collation guaranteed the mismatch against a fresh baseline instead of removing it. It now converges on the database’s own default, and any other table that drifts is logged by name.
Removed
-
Config blocks nothing implemented (#53).
[streaming.rtsp_proxy]declared a port and an RTP range that nothing bound, and[streaming.go2rtc]a base URL that nothing called — an operator could configure either, restart, and get no behaviour change and no warning. Both are gone, along with an unused request DTO.Monitors.Go2RTCEnabledstays: that is ZoneMinder’s own column and the response passes it through. Removing them is upgrade-safe — no config struct denies unknown fields, so a stale block in an existing file is ignored rather than refusing to start, and there is a test for that. -
Enums emitted Rust variant names instead of the values ZoneMinder stores.
#[sea_orm(string_value = …)]governs only the database mapping, so serde fell back to the variant name:/monitorsreportedRotate90where the column holdsROTATE_90, andCurlwhere it holdscURL. Nine enums were affected —Orientation,MonitorType,DefaultCodec,EventCloseMode,Rtsp2WebType,Decoding,OutputContainer,StorageType,SynopsisStatus. It was self-consistent, and therefore invisible: requests accepted the same wrong spelling responses emitted, so a client that only ever talked to this API round-tripped fine while anything that knows ZoneMinder’s real values silently mismatched.
Responses and the OpenAPI schema now carry the DB values. The previous spelling is still accepted on input via a serde alias, so clients keep working while they migrate. A test walksActiveEnum::values()for every affected enum, so a newly generated one is covered without anyone remembering. -
A configured TURN server had no effect.
AppStatebuilt the WebRTC engine from defaults rather than the loaded[streaming.webrtc]config, sostun_serversandturnwere parsed, validated, and discarded — viewers behind symmetric NAT could not connect, with nothing in the log to explain it. -
CORS was undiscoverable. The allowed-origin list came from a bare, un-prefixed
ALLOWED_ORIGINSvariable that appeared in no config file and no documentation, defaulting to localhost. A dashboard on any other origin was silently blocked with no string in the repo to grep for. The variable is still honoured (with a deprecation warning) so existing deployments keep working; the effective list and its source are now logged at startup. -
APP_DAEMON__SCRIPT_PATHshipped a value wrong for Debian and Ubuntu. One env file serves all three package formats, and no single value suits every distribution, so daemon paths are now resolved by searching the standard locations, with the setting as an override. -
Requires=mariadb.servicefailed the unit on hosts usingmysql.serviceor a remote database. The unit is still orderedAfter=both, andRestart=on-failurecovers a slow database. -
A stream socket the service user cannot open now reports that
ZM_STREAM_SOCKET_GROUPmembership is missing, rather than a bare “permission denied” naming nothing actionable. -
docs/tls.mdclaimed the systemd unit usesDynamicUser; it runs asUser=zoneminder.
Changed
-
BREAKING: six
operationIds renamed (#32). They were duplicated, which meant a generated client silently got one method and lost the other, so this had to change — but it renames methods for anyone already generating against the spec.list_models→list_ai_models,create_model→create_ai_model,get_model→get_ai_model,update_model→update_ai_model,delete_model→delete_ai_model(the AI registry; the camera-model routes keep the plain names).update_stateon/monitors/{id}/state→update_monitor_state, and on/states/{id}→update_state_preset.
The compatibility gate did not catch this on its first run — it compared paths, response shapes and schemas but not operation ids. It does now, which is how the list above was produced. -
BREAKING:
rate_limit_per_secondrenamed torate_limit_period_secs(#70). The old name read as a rate and meant a period. It is still accepted as an alias, so no configuration needs changing, but the old spelling is misleading enough that it should not be used in new files. -
BREAKING:
GET /api/v3/mereturns a wrapper, not a bare user. As of5ce04e5the response isMeResponse—{ user, issued_at, expires_at, token_type }— where it was previouslyUserResponsewith the eight permission columns at the top level. This shipped without a changelog entry and broke zm-web’s permission gating: reading the wrapper as a user finds no permission columns, and absent columns fail closed toNone, so the camera wall and every edit control disappeared.
Clients should readresponse.user. Accepting both shapes is worth it while older backends are still deployed. -
BREAKING: the project is named
zm-apithroughout, including on disk. The binary is/usr/bin/zm-api, config lives in/etc/zm-api/, state in/var/lib/zm-api/, logs in/var/log/zm-api/, and the unit iszm-api.service; the helpers arezm-api-dbandzm-api-takeover, and the man pages match. The distribution packages were already calledzm-api— only what they installed disagreed. Nothing has been released, so there is no upgrade path to migrate; a pre-release install should be removed and reinstalled. The Rust crate is still imported aszm_api, which is the normal Cargo mapping for a hyphenated package name. -
packaging/install.shrewritten for source installs: it now matches the package layout, installs the man pages andzm-api-db, and runssetup-instance.shto generate JWT keys. Previously it installed the unit to a different directory than the packages, never generated keys, and left a freshly “installed” service unable to sign a token. -
Removed the dead
[package.metadata.rpm]block fromCargo.toml; nothing invoked cargo-rpm, and it duplicatedpackaging/rpm/zm-api.spec.
[3.0.0-alpha.1]
First Rust release, replacing ZoneMinder’s Perl/PHP/CGI API surface with a single native service. It talks directly to an existing ZoneMinder MySQL/MariaDB database and ships in passive mode, serving only the REST API so it can be installed alongside a running ZoneMinder without touching its daemons.
Added
- REST API under
/api/v3for monitors, events, frames, zones, groups, users, storage, controls, PTZ presets, and configuration — with an auto-generated OpenAPI 3 spec at/api-docs/openapi.jsonand Swagger UI at/swagger-ui. - Live streaming over WebRTC and HLS, sourced from zmc’s per-monitor stream socket (video and audio on one connection with a HELLO codec handshake).
- Event playback — VOD, fragmented-MP4 streaming, thumbnails, and motion synopsis.
- Authentication and authorisation — RS256 JWTs with separate access and refresh key pairs, server-side revocation, feature-level RBAC, and row-level monitor ACLs.
- Daemon supervision (takeover mode) — one native supervisor replacing both
zmdc.plandzmwatch.pl, with exponential backoff, database reconciliation, REST daemon control, and azmdc.sockcompatibility shim.zm-api-takeoverswitches a host between modes in one command, either way. - Retention — automatic recording cleanup bounded by free-space floor, age, and per-storage quota, deleting media and database rows together.
- ONVIF device discovery, media profiles, PTZ, and event pull-point.
- Natural-language event search over MariaDB 11.8 native vectors.
- Object-detection registry — CRUD over ZoneMinder 1.39’s
AI_Datasets,AI_Models, andAI_Object_Classes, plus the per-monitor detection columns. - Packaging for Debian/Ubuntu (
.deb), Fedora/RHEL/openSUSE (.rpm), and Arch (PKGBUILD), with a systemd unit, per-install JWT key generation, and built-in TLS/ACME.
Notes
- Upgrading an existing ZoneMinder database requires
zm-api-db bridge -u mysql://...before first start. Only a fresh, empty database should usezm-api-db up. - Passive mode is the install-time default so the package cannot disturb a
running ZoneMinder. Takeover is the intended destination — one native
supervisor replacing
zmdc.plandzmwatch.pl— andzm-api-takeoverswitches either way in one command.