HTTP API Reference¶
This document describes the HTTP endpoints exposed by blackvuesync-v2 serve
(and the Docker container on port 8080).
Conventions¶
- Authentication. Endpoints marked as requiring authentication follow
auth.mode. When a request is not authenticated: /api/*returns401with{"error": "authentication required", "code": "AUTH_REQUIRED", "details": {}};- htmx requests (
HX-Request: true) return401with anHX-Redirect: /login?next=...header; - pages redirect (
302) to/login?next=.... - Sessions are signed cookies. They end when the password changes (other
than the session that made the change), when sessions are rotated, or after
web.session_lifetime_hours. - CSRF.
POST,PATCHandDELETEneed theX-CSRFTokenheader (or acsrf_tokenform field). The token comes from the page's<meta name="csrf-token">and does not expire. - Errors use
{"error": "...", "code": "...", "details": {...}}. - Server-Sent Events. At most 16 streams (
/api/sync/progress/streamand/api/logs/streamcombined) are open at once. Beyond that the server returns503with codeTOO_MANY_STREAMSandRetry-After: 5. - Proxies.
X-Forwarded-*headers are honored (one hop) only whenBLACKVUESYNC_TRUST_PROXYis set.
Health Endpoints¶
These endpoints are exempt from authentication and from the first-run redirect.
GET /healthz¶
Liveness probe. Returns 200 OK immediately.
{"status": "ok"}
GET /readyz¶
Readiness probe. Returns 200 OK once the settings store has loaded.
The 503 Service Unavailable branch exists for a missing settings store; in
practice the app is only constructed after the store loads, so it always
returns 200.
{"status": "ready", "settings_loaded": true}
{"status": "starting", "settings_loaded": false}
Auth Endpoints¶
GET /first-run¶
Displays the first-run setup wizard. Redirected to automatically when
auth.password_hash is empty.
POST /first-run¶
Submits the initial password. Requires the X-CSRFToken header (or
csrf_token form field). Password must be at least 12 characters.
| Field | Required | Description |
|---|---|---|
username |
no | Admin username; defaults to admin |
password |
yes | Initial admin password (min 12 chars) |
confirm |
yes | Confirmation; must match password |
On success: redirects to /login. If a password is already set, both GET
and POST redirect to /login.
On error: re-renders the form with a validation message and status 400.
GET /login¶
Renders the login form. Redirected to when a protected page is accessed
without a valid session (auth mode login only).
POST /login¶
Authenticates the user. Requires the X-CSRFToken header.
| Field | Required | Description |
|---|---|---|
username |
yes | Admin username (from auth.username) |
password |
yes | Admin password |
next |
no | Relative path to return to; values with a scheme or host are replaced by / |
- On success: sets a session cookie and redirects to
next, or to/. - On failure: re-renders the form with a generic error after a minimum delay of 1.5 seconds (uniform-timing defence).
- After 10 failures from the same IP within 10 minutes: requests are rejected for 15 minutes.
POST /logout¶
Clears the session and redirects to /login. Requires an active session
(or auth mode none/proxy).
UI Endpoints¶
All UI endpoints require authentication (subject to auth.mode).
| Method | Path | Description |
|---|---|---|
GET |
/ |
Dashboard: sync status, live progress, controls, cards |
GET |
/settings |
Settings editor |
GET |
/logs |
Live log viewer |
GET |
/stats |
Run statistics and disk forecast |
GET |
/viewer |
In-browser recording viewer |
Sync API Endpoints¶
All endpoints below require authentication (subject to auth.mode).
CSRF protection applies to all POST requests (Flask-WTF global protection).
When a download is interrupted, the partial file is preserved and the next
sync resumes it via an HTTP range request (Range: bytes=N-). If the dashcam
responds with 200 instead of 206, the sync falls back to a full
re-download.
GET /api/sync/progress¶
Returns the current sync progress snapshot as JSON.
Response (200 OK):
{
"job_id": "a1b2c3d4e5f6...",
"started_at_wall": 1747603200.0,
"state": "running",
"current_file": {
"filename": "20230101_120000_NF.mp4",
"recording_base": "20230101_120000_NF",
"artifact": "mp4",
"direction": "F",
"total_bytes": 52428800,
"downloaded_bytes": 10485760,
"bytes_per_second": 2097152.0,
"eta_seconds": 20.0,
"state": "downloading",
"failure_reason": null
},
"files_total": 12,
"files_completed": 3,
"files_failed": 0,
"files_skipped": 40,
"bytes_downloaded_total": 157286400
}
When no sync has run, state is "idle" and most fields are zero.
File counts are per file (video, thumbnail, accelerometer and GPS files each
count once; types excluded by sync.skip_metadata are not counted). A job
starts with files_total 0 while the dashcam is listed, so listing failures
still end the job as "failed". Files that need no transfer (already
downloaded, or blocked by a recent failure marker) are counted in
files_skipped and removed from files_total, so files_completed +
files_failed reaches files_total at the end of a successful run.
GET /api/sync/progress/stream¶
Server-Sent Events (SSE) stream of progress updates.
Response headers:
Content-Type: text/event-stream
Cache-Control: no-store
X-Accel-Buffering: no
Event format:
event: progress
data: {"state": "running", "files_completed": 3, ...}
Events are throttled to 5 Hz. When no state change occurs for 30 seconds, a keepalive comment is emitted to keep the connection alive:
: keepalive
Example (curl):
curl -N -H "Cookie: bvs_session=<token>" \
http://localhost:8080/api/sync/progress/stream
POST /api/sync/now¶
Triggers an on-demand sync. Requires the X-CSRFToken header.
Response (202 Accepted) -- sync started:
{"job_id": "a1b2c3d4e5f6..."}
Response (409 Conflict) -- sync already running:
{
"error": "sync already running",
"code": "SYNC_ALREADY_RUNNING",
"details": {"current_job_id": "a1b2c3d4e5f6..."}
}
Example (curl):
curl -X POST \
-H "Cookie: bvs_session=<token>" \
-H "X-CSRFToken: <token>" \
http://localhost:8080/api/sync/now
GET /api/sync/last¶
Returns the current sync snapshot when it is not idle: a running job, or a completed/failed job during its 10-second retention window.
- 204 No Content -- the publisher is idle: no sync has run, or more than
10 seconds have passed since the last one ended. Completed-run history is
available from
/api/stats/series. - 200 OK -- returns the same JSON body as
/api/sync/progress.
HTMX Fragment Endpoints¶
These endpoints return HTML fragments intended for use with HTMX polling
(hx-get, hx-trigger="every 5s"). Both require authentication.
GET /hx/sync/status-card¶
Returns the sync-status-card HTML partial showing current sync state,
progress bar, and current file information.
GET /hx/sync/last-run-card¶
Returns the last-run-card HTML partial. While a job is running, or within
10 seconds of it ending, it shows the live progress snapshot. Otherwise it
shows the most recent run recorded in the stats store: how long ago, the start
time in the server's timezone (TZ), the outcome, files and bytes.
Settings API Endpoints¶
All endpoints below require authentication (subject to auth.mode).
CSRF protection applies to all PATCH requests (Flask-WTF global protection).
GET /api/settings¶
Returns the full settings object as JSON. Every section carries a _tier
annotation (immediate, next_tick, or restart) indicating how quickly
a change to that section propagates. Secret fields (currently
auth.password_hash and auth.session_secret) are always replaced with the
sentinel string "***" regardless of whether they are empty. This prevents
the redacted snapshot from leaking the first-run state (password_hash == "")
and lets clients safely round-trip the response back through a PATCH.
Response (200 OK), truncated example:
{
"version": 1,
"connection": {
"address": "192.168.0.1",
"timeout_seconds": 10.0,
"_tier": "restart"
},
"auth": {
"mode": "login",
"username": "admin",
"password_hash": "***",
"session_secret": "***",
"trusted_proxies": [],
"_tier": "immediate"
},
"sync": {
"priority": "date",
"grouping": "none",
"include": [],
"exclude": [],
"retry_failed_after": "1d",
"skip_metadata": [],
"_tier": "next_tick"
}
}
PATCH /api/settings/<section>¶
Updates a single settings section partially. The request body is a JSON
object containing only the fields to change; missing fields are left
unchanged. Fields whose value is the redaction sentinel "***" are stripped
before applying, so a client may post back the full GET response without
overwriting secrets. Any other value for auth.password_hash or
auth.session_secret is rejected with 422; use POST /api/auth/password
and DELETE /api/auth/sessions. Each field is type-checked against the
settings schema (for example web.port must be an integer, schedule.paused
a boolean, list fields arrays of strings); a wrong type is a 422, never a
500. JSON arrays are coerced to tuples for the
sync.include, sync.exclude, sync.skip_metadata, and
auth.trusted_proxies fields so the in-memory dataclass remains tuple-typed
(JSON has no tuple).
Request body example (PATCH /api/settings/sync):
{
"priority": "rdate",
"include": ["P", "NF"]
}
Response (200 OK):
{"section": "sync", "tier": "next_tick", "applied": true}
Error responses:
400 Bad Request--INVALID_BODYwhen the request body is not a JSON object.404 Not Found--SECTION_NOT_FOUNDwhen<section>is not a known settings section.422 Unprocessable Entity--SETTINGS_INVALIDwhen the payload contains an unknown field or fails section-level validation. Thedetails.field_errorsarray enumerates the failing paths and messages.
{
"error": "settings validation failed",
"code": "SETTINGS_INVALID",
"details": {
"field_errors": [
{"path": "sync", "message": "sync.priority must be one of ['date', 'rdate', 'type'], got 'bogus'"}
]
}
}
Auth API Endpoints¶
All endpoints below require authentication (subject to auth.mode).
CSRF protection applies to all POST and DELETE requests.
GET /api/auth/me¶
Returns the current authenticated user and the active auth mode. The mode is
read fresh from the settings store on every request, so a mode change in
/config/settings.json takes effect immediately without a restart.
Response (200 OK):
{"username": "admin", "mode": "login"}
POST /api/auth/password¶
Changes the current user's password. Requires the current password as well
as the new password (minimum 12 characters). Failures consume the same
rate-limit bucket as POST /login (10 failures from the same IP within
600 seconds triggers a 15-minute lockout). Both fields must be strings
(422 VALIDATION_ERROR otherwise). On success every other session is signed
out; the caller's session is re-issued and stays valid.
Request body:
{"current_password": "<old>", "new_password": "<new>"}
Response (200 OK):
{"applied": true}
Error responses:
400 Bad Request--INVALID_BODYwhen the request body is not a JSON object.401 Unauthorized--INVALID_CURRENT_PASSWORDwhen the current password does not match the stored hash; also increments the rate-limit bucket.422 Unprocessable Entity--WEAK_PASSWORDwhen the new password is shorter than 12 characters.429 Too Many Requests--RATE_LIMITEDwhen the IP has exceeded the shared/loginfailure threshold.
DELETE /api/auth/sessions¶
Rotates the session secret (auth.session_secret) to a fresh random value.
The running app switches to the new secret immediately, so every existing
session, including the caller's, is signed out without a restart.
Response (200 OK):
{"rotated": true, "restart_required": false}
Health API Endpoints¶
GET /api/health/storage¶
Returns storage usage at the destination directory. Uses shutil.disk_usage
so the used_percent value matches what the sync engine sees for its
max_used_disk_percent threshold check (root-reserved blocks count as free).
{
"available": true,
"destination": "/recordings",
"total_bytes": 137438953472,
"free_bytes": 50725394432,
"used_bytes": 86713559040,
"used_percent": 63.1,
"recording_count": 481
}
When the destination does not exist on disk:
{"available": false, "reason": "destination not configured"}
GET /api/health/dashcam¶
HEAD-probes http://<settings.connection.address>/blackvue_vod.cgi with a
2-second timeout. The fixed timeout intentionally diverges from
connection.timeout_seconds so a slow dashcam does not block the dashboard.
Success:
{"reachable": true, "address": "192.168.1.50", "latency_ms": 38.0}
Failure (URLError wrapping a timeout is classified as reason: "timeout"
for ui consistency with socket.timeout):
{"reachable": false, "address": "192.168.1.50", "reason": "timeout"}
When no address is configured:
{"reachable": false, "reason": "no address configured"}
Dashcam API Endpoints¶
GET /api/dashcam/info¶
Read-only inspection of the dashcam's on-camera configuration. Fetches
http://<address>/Config/version.bin and http://<address>/Config/config.ini
(BlackVue firmware is HTTP-only), parses them defensively, and returns
structured JSON. Changing settings is deliberately out of scope (a future
sub-project); this endpoint never writes to the camera, and Wi-Fi passwords in
config are always "***" (so are unknown keys named like passwords). firmware
is "<model> · fw <version>".
Available (firmware may be null if version.bin was unreachable while config.ini succeeded -- partial availability still reports available: true):
{
"available": true,
"address": "192.168.1.50",
"firmware": "DR900X Plus · fw 1.015",
"config": {"Tab1": {"TimeZone": "1000", "VideoQuality": "0"},
"Wifi": {"ap_ssid": "Blackvue900XPlus-000000", "ap_pw": "***"}},
"setting_count": 4
}
Unreachable or no address configured:
{"available": false, "reason": "dashcam unreachable"}
{"available": false, "reason": "no address configured"}
Camera API Endpoints¶
GET /api/camera/config¶
Reads the camera's config.ini live (timeout camera.read_timeout_seconds);
when the camera is unreachable, serves the last snapshot with online: false.
Passwords are masked (value is eight bullets, raw is null), including
keys the schema does not know that are named like passwords (ending _pw or
containing password, passwd or pwd).
{"available": true, "online": true, "read_at": "2026-10-05T01:02:03+00:00",
"model": "DR900X Plus", "firmware": "1.015",
"tabs": [{"name": "basic", "label": "Basic", "section": "Tab1",
"fields": [{"key": "Tab1.TimeZone", "label": "Time zone", "value": "UTC+10:00",
"raw": "1000", "secret": false, "has_value": true,
"formats_card": true, "help": ""}],
"other": [], "not_fitted": []}],
"changed": [{"key": "Tab3.VOLUME", "label": "Volume", "from": "5", "to": "4"}]}
Before the first successful read: {"available": false, "online": false}.
GET /api/camera/secret?key=<Section.key>¶
Returns one decrypted password from the snapshot: {"key": "Cloud.sta_pw",
"value": "..."} with Cache-Control: no-store. 404 for any key that is not a
password, or when there is no snapshot; 422 UNDECODABLE_PASSWORD when the
stored value does not decrypt to text.
GET /hx/camera/panes¶
The htmx fragment behind Settings → Camera (one pane per tab).
Recordings API Endpoints¶
GET /api/recordings/recent¶
Returns the N most recently modified BlackVue recordings at the destination
(matched via filename_re.fullmatch). Default limit is 5; clamped to
[1, 50] via query param ?limit=N.
{
"recordings": [
{
"filename": "20231015_120000_NF.mp4",
"mtime": 1697371200.0,
"path": "/recordings/20231015_120000_NF.mp4"
}
],
"total": 1
}
Schedule API Endpoints¶
POST /api/schedule/pause¶
Sets settings.schedule.paused = true. The next scheduled sync is skipped
(the scheduler logs scheduled sync skipped: schedule is paused). Manual
POST /api/sync/now is unaffected -- operators can still trigger ad-hoc
syncs.
{"paused": true}
POST /api/schedule/resume¶
Sets settings.schedule.paused = false. Idempotent: returns 200 even when
the schedule was already running.
{"paused": false}
Logs API Endpoints¶
All endpoints below require authentication (subject to auth.mode).
| Method | Path | Description |
|---|---|---|
GET |
/api/logs/recent |
JSON snapshot: {lines, file_path, capacity, verbosity} |
GET |
/api/logs/stream |
SSE stream of new log lines (event: logs) |
GET /api/logs/recent¶
Returns the current in-memory log buffer snapshot as JSON.
Response (200 OK):
{
"boot_id": "3f2a...",
"lines": [
{
"seq": 1,
"ts": "2026-01-01T12:00:00.000Z",
"level": "INFO",
"level_no": 20,
"logger": "blackvuesync",
"message": "sync started"
}
],
"file_path": "/recordings/blackvuesync.log",
"capacity": 500,
"verbosity": "normal"
}
boot_id identifies the server process. seq restarts at 1 in each process,
so a client that sees a new boot_id (or a lower seq) resets its position.
file_path is "" (empty string) when no rotating file handler is active. verbosity reflects
the current logging.verbose / logging.quiet setting: "quiet" when quiet,
otherwise "normal" (verbose 0), "verbose" (1), or "debug" (2 or more).
GET /api/logs/stream¶
Server-Sent Events (SSE) stream of new log lines.
Response headers:
Content-Type: text/event-stream
Cache-Control: no-store
X-Accel-Buffering: no
Event format:
event: logs
data: {"boot_id": "3f2a...", "lines": [...]}
The first frame contains the full current buffer snapshot (same lines as
/api/logs/recent). Subsequent frames contain only the new lines since the
previous emit. When no new lines arrive for 30 seconds a keepalive comment
is emitted:
: keepalive
Each line object carries the same fields as in /api/logs/recent:
{seq, ts, level, level_no, logger, message}.
The viewer changes capture verbosity through the existing
PATCH /api/settings/logging; there is no dedicated verbosity endpoint.
Statistics API Endpoints¶
All endpoints below require authentication (subject to auth.mode).
| Method | Path | Description |
|---|---|---|
GET |
/api/stats/series?range=24h\|7d\|30d\|all |
JSON {range, summary, series, forecast} |
summary = {runs, offline, reachable_runs, bytes, avg_duration_seconds, success_rate};
series.points[] = {ts, bytes, files, duration, disk, success, dry_run, offline, failures{reason}}.
A run is offline when it failed and its only non-zero failure reasons are
network / timeout (the dashcam was not reachable, e.g. the car was away).
success_rate and avg_duration_seconds cover only the reachable_runs;
success_rate is null when no run in the range reached the dashcam.
(dry-run rows are excluded from the bytes total);
forecast = {projected[{ts, disk}], limits{max_used_disk_percent, keep_steady_state}}
(disk / limit values are 0..1 ratios). Per-run rows are captured in serve mode and
stored in the SQLite stats DB (/config/stats.db); stats.retention_days prunes
old rows.
Viewer API¶
Login required. See docs/reference/blackvue-file-formats.md for the underlying
file formats. viewer.journey_mode / viewer.speed_unit settings tune the page;
viewer.continuous_play decides whether /journey links segments of every type.
| Method | Path | Description |
|---|---|---|
| GET | /api/viewer/days |
{"days": [{date, count}]}, newest day first |
| GET | /api/viewer/recordings?date=YYYY-MM-DD |
one day's recordings, newest first; without date, the newest day |
| GET | /api/viewer/recordings/<base>_<type>/journey |
forward chain of contiguous segments (starts within 120 s of the previous); every type with viewer.continuous_play, else the start's type only |
| GET | /api/viewer/recordings/<base>_<type>/gps |
{"points": [{t, lat, lon, speed}]} |
| GET | /api/viewer/recordings/<base>_<type>/gsensor |
{"samples": [{t, x, y, z}]} |
| GET | /media/<path> |
path-safe .mp4/.thm serving (HTTP Range) |
/recordings returns {"days": [{"date", "recordings": [...]}]} holding at
most one day, so a library of tens of thousands of recordings is never sent in
one response. An unknown date returns {"days": []}; a malformed one returns
422 with code INVALID_DATE. The viewer page lists /days and loads a
day's recordings when it is opened.
All viewer endpoints read a shared in-memory index that is refreshed per
directory by modification time: a request costs one stat per directory and
re-lists only directories whose contents changed. Hidden directories and NAS
system directories (@eaDir, #recycle, #snapshot) are skipped.
Settings UI (Sub-Project #3)¶
The /settings page added in Sub-Project #3 drives the existing
GET /api/settings and PATCH /api/settings/<section> endpoints for
all eleven sections (connection, schedule, sync, retention, logging,
metrics, stats, viewer, web, auth, system; stats and viewer arrived with
Sub-Projects #5 and #6). It also drives POST /api/auth/password
(change-password dialog) and DELETE /api/auth/sessions (rotate
sessions button). No new API endpoints were added in Sub-Project #3.
Dashboard Controls (Phase 2C)¶
The dashboard UI added in Phase 2C drives Sync-now, Stop (modal-confirmed), and
Pause/Resume directly against the existing /api/sync/* and /api/schedule/*
endpoints listed in this document. No new endpoints were added in 2C. The
dashboard.js Alpine component opens an SSE subscription to
/api/sync/progress/stream on page load and reflects SyncProgress.state onto
body[data-state]; CSS drives the active/idle layout swap.
Sync API Endpoints (additions)¶
POST /api/sync/stop¶
Requests cooperative stop of the active sync. The download chunk loop in
sync.py checks the stop flag between chunks and raises
UserWarning("sync stopped by user") on its next boundary, which the
existing exception classifier routes through the normal failed exit path.
The partial .filename.mp4 dotfile survives; the next sync resumes it
naturally.
Returns 202 when a sync was running:
{"job_id": "deadbeef...", "stopping": true}
Returns 404 when no sync is active:
{"error": "no sync is running", "code": "SYNC_NOT_RUNNING", "details": {}}
HTMX Fragment Endpoints (additions)¶
Dashboard card fragments. Each renders the matching _partials/*.html
template, which includes an hx-trigger attribute so the card self-polls
once the dashboard embeds it.
GET /hx/storage-card-- renders_partials/storage_card.htmlwith the same data as/api/health/storageGET /hx/dashcam-card-- renders_partials/dashcam_card.htmlwith the same data as/api/health/dashcamGET /hx/next-scheduled-card-- renders_partials/next_scheduled_card.htmlwith the next cron fire time, paused flag, cron expression, and timezoneGET /hx/recent-activity-card-- renders_partials/recent_activity_card.htmlwith the same data as/api/recordings/recent(defaultlimit=5)GET /hx/dashcam-info-card-- renders_partials/dashcam_info_card.html. Loads once on page load and refreshes every 60s (config is near-static and the fetch is two files, so it polls slower than the 5s cards).