Shared contract between server/ and app/. JSON everywhere, ISO 8601 UTC timestamps, ids are short url-safe strings.
Authorization: Bearer <token>on every/api/*call except the public ones below.- Media URLs (
*.m3u8,*.ts,*.m4s,*.mp4,*.jpg,/player/*) also accept?token=<token>because native players can't always send headers. - Roles:
admin(everything) andviewer(live, recordings, events; no settings).
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/info (public) |
{ name, version, setupRequired, demo, features: string[], siteId? } |
|
| POST | /api/auth/setup (public, only while no admin exists) |
{ username, password } |
{ token, user } |
| POST | /api/auth/login (public) |
{ username, password, deviceName? } |
{ token, user } |
| POST | /api/auth/pair (public) |
{ code, deviceName? } |
{ token, user } (one-time pairing code from the web UI QR) |
| POST | /api/auth/pairing-code (admin) |
{ role? } |
{ code, expiresAt, url } url = opencctv://pair?server=<baseUrl>&code=<code> |
| GET | /api/auth/me |
{ user } |
|
| POST | /api/auth/logout |
{ ok } |
|
| GET/POST/PATCH/DELETE | /api/users[/:id] (admin) |
{ username, password?, role } |
User[] / User |
User = { id, username, role, createdAt }
type Camera = {
id: string; name: string; brand: BrandId; group?: string; order: number; enabled: boolean;
source: { kind: 'rtsp' | 'onvif' | 'tapo' | 'unifi' | 'eufy' | 'http' | 'rtmp-push' | 'rtsp-push' | 'demo'; url: string /* password masked as *** */; subUrl?: string };
recording: { mode: 'continuous' | 'motion' | 'off'; useSubstream: boolean };
motion: { enabled: boolean; sensitivity: number /* 1-10 */; notify: boolean };
capabilities: { audio: boolean; twoWayAudio: boolean; ptz: boolean; substream: boolean };
status: { online: boolean; recording: boolean; lastSeen?: string; codec?: string; width?: number; height?: number; fps?: number; bitrateKbps?: number; error?: string };
push?: { url: string } // only for *-push kinds: the URL the camera must publish to
}
type BrandId = 'tapo' | 'eufy' | 'unifi' | 'reolink' | 'hikvision' | 'dahua' | 'amcrest' | 'axis' | 'foscam' | 'ezviz' | 'imou' | 'annke' | 'wyze' | 'ubiquiti' | 'onvif' | 'generic' | 'demo'| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/brands |
Brand[] = { id, name, kinds, fields: Field[], help: string, defaultPort? }; `Field = { key, label, type: 'text' |
|
| GET | /api/cameras |
Camera[] |
|
| POST | /api/cameras |
{ name, brand, kind?, fields: Record<string,string> } or { name, brand:'generic', url, subUrl? } |
Camera |
| PATCH | /api/cameras/:id |
partial Camera (name, group, order, enabled, recording, motion, fields/url) | Camera |
| DELETE | /api/cameras/:id |
{ ok } |
|
| POST | /api/cameras/test |
same as POST create | { ok, error?, codec?, width?, height?, audio?, snapshot?: base64jpeg } |
| POST | /api/cameras/reorder |
{ ids: string[] } |
{ ok } |
| GET | /api/cameras/:id/snapshot.jpg |
?w=640 optional |
JPEG (cached ≤ 2 s) |
| GET | /api/cameras/:id/live.m3u8 |
?quality=hd|sd |
HLS playlist (fMP4), child URLs carry the token |
| POST | /api/cameras/:id/webrtc |
{ sdp, quality? } (offer) |
{ sdp } (answer) |
| GET | /api/cameras/:id/mjpeg |
multipart MJPEG | |
| GET | /player/:id |
?token=&quality=&muted=1 |
minimal HTML page that plays the camera with WebRTC → MSE → HLS fallback, full-bleed, black background (used in a WebView for low latency and two-way audio) |
| POST | /api/cameras/:id/ptz |
{ action:'move', pan, tilt, zoom } (-1..1) / { action:'stop' } / { action:'preset', preset } |
{ ok } |
| GET | /api/discover |
{ candidates: { host, port, brand?, name?, model?, onvif: boolean, rtsp: boolean, alreadyAdded: boolean }[] } (ONVIF WS-Discovery + LAN port probe, ≤ 8 s) |
|
| POST | /api/integrations/unifi/import |
{ host, username, password, cameraIds? } |
{ cameras: { id, name, model, added: boolean }[] } |
| GET | /api/integrations/eufy (admin) |
EufyAccount |
|
| POST | /api/integrations/eufy/login (admin) |
{ email, password, country } (ISO country of the eufy account, e.g. DE) |
EufyAccount (status is connected, 2fa, captcha or error) |
| POST | /api/integrations/eufy/verify (admin) |
{ code } (code eufy sent by email) |
EufyAccount |
| POST | /api/integrations/eufy/captcha (admin) |
{ answer } |
EufyAccount |
| POST | /api/integrations/eufy/refresh (admin) |
EufyAccount |
|
| POST | /api/integrations/eufy/import (admin) |
{ serials?: string[] } |
{ cameras: { id, name, model, added: boolean, cameraId?, error? }[] } |
| DELETE | /api/integrations/eufy (admin) |
EufyAccount (signs out, cameras stay but go offline) |
type EufyAccount = { status: 'disconnected' | 'connecting' | 'captcha' | '2fa' | 'connected' | 'error'; email?: string; country?: string;
captcha?: string /* data URL of the image to solve */; method?: string; error?: string;
cameras: { serial: string; name: string; model: string; battery: boolean; batteryLevel?: number; rssi?: number /* WiFi dBm */; station?: string; added: boolean; cameraId?: string }[] }eufy cameras (source.kind: 'eufy') are reached through the eufy account, the way the eufy app does it, so models without RTSP work. The stream is pulled only while someone watches or a recording runs; imported cameras start with recording off and motion events come from the camera itself. The eufy client runs in a Node.js 24.5+ worker process next to the server (bundled in the Docker image, found on PATH or downloaded once otherwise; override with OPENCCTV_NODE).
type Recording = { id: string; cameraId: string; start: string; end: string; durationSec: number; sizeBytes: number;
location: 'local' | 'remote' | 'both'; uploaded: boolean; motion: boolean; videoUrl: string; thumbUrl: string }
type MotionEvent = { id: string; cameraId: string; start: string; end?: string; score: number; snapshotUrl: string; recordingId?: string; offsetSec?: number }| Method | Path | Query/Body | Response |
|---|---|---|---|
| GET | /api/recordings |
camera, from, to, limit, cursor |
{ items: Recording[], nextCursor? } |
| GET | /api/recordings/:id/video.mp4 |
MP4 with HTTP Range support (local file or streamed from remote storage) | |
| GET | /api/recordings/:id/thumb.jpg |
JPEG | |
| DELETE | /api/recordings/:id (admin) |
{ ok } |
|
| GET | /api/timeline |
camera, day=YYYY-MM-DD, tz=Europe/Berlin |
{ ranges: { start, end, recordingId }[], events: MotionEvent[], days: string[] /* days with footage */ } |
| GET | /api/events |
camera?, from?, before?, limit |
{ items: MotionEvent[], nextCursor? } |
| GET | /api/events/:id/snapshot.jpg |
JPEG | |
| POST | /api/clips |
{ cameraId, start, end } |
{ url } (MP4 export of a time range, for sharing) |
type StorageTarget = { id: string; type: 'local' | 'gdrive' | 's3' | 'ftp' | 'sftp' | 'smb' | 'webdav' | 'dropbox' | 'onedrive';
name: string; enabled: boolean; config: Record<string, string> /* secrets masked as *** */; path: string /* folder inside the target */;
retentionDays: number; status: { ok: boolean; lastUpload?: string; usedBytes?: number; queued: number; error?: string } }| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/storage |
{ local: { path, usedBytes, freeBytes, totalBytes, retentionDays, maxGB }, targets: StorageTarget[], types: { type, name, fields: Field[], help }[] } |
|
| POST | /api/storage/targets |
{ type, name, config, path, retentionDays } |
StorageTarget |
| PATCH/DELETE | /api/storage/targets/:id |
partial | StorageTarget / { ok } |
| POST | /api/storage/targets/test |
same as POST | { ok, error? } |
| POST | /api/storage/gdrive/start |
{ name?, path? } |
{ flowId, verificationUrl, userCode, expiresAt } (OAuth device flow) or { flowId, authUrl } (browser flow) |
| GET | /api/storage/gdrive/:flowId |
{ status: 'pending' | 'done' | 'expired' | 'error', target?: StorageTarget, error? } |
| Method | Path | Body | Response |
|---|---|---|---|
| GET/PATCH | /api/settings (admin for PATCH) |
{ serverName, recording: { segmentSeconds, preMotionSec, postMotionSec }, retention: { localDays, maxLocalGB, deleteLocalAfterUpload: boolean }, motion: { defaultSensitivity }, notifications: { enabled, cooldownSec }, gateway: { url?, siteName?, connected: boolean, enabled: boolean } } |
|
| GET | /api/system |
{ version, uptimeSec, platform, cpuPercent, memBytes, disk: {...}, components: { go2rtc, ffmpeg, rclone }: { version, ok }, cameras: n, recordingsCount, recordingsBytes } |
|
| GET | /api/system/logs (admin) |
?lines=200 |
{ lines: string[] } |
| POST | /api/push/register |
{ expoPushToken, platform, cameras?: string[] } |
{ ok } |
| DELETE | /api/push/register |
{ expoPushToken } |
{ ok } |
| WS | /api/ws?token= |
server pushes { type: 'camera', camera }, { type: 'motion', event }, { type: 'recording', recording }, { type: 'storage', target } |
Any OpenCCTV server can act as a gateway (e.g. deployed on a VPS/Coolify). Home servers ("sites") keep an outbound WebSocket tunnel to it; the app talks to the gateway and picks a site.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/sites |
{ items: { id, name, online, lastSeen, version, cameras }[] } |
|
| POST | /api/sites (admin) |
{ name } |
{ id, name, linkCode } link code = what the home server enters |
| DELETE | /api/sites/:id (admin) |
{ ok } |
|
| POST | /api/gateway/link (admin, on the home server) |
{ url, linkCode } |
{ ok, siteId } |
| DELETE | /api/gateway/link (admin, on the home server) |
{ ok } |
|
| WS | /api/gateway/tunnel |
site auth | internal tunnel protocol |
| ANY | /s/:siteId/* |
everything under /api and /player of that site, proxied through the tunnel (streaming, Range and WebSocket aware). Gateway users act as admin on the site. |
The app treats https://gateway/s/<siteId> as just another server base URL.
Additions made while implementing the server. All are backwards compatible.
- Errors are
{ error: string }with a non-2xx status.503 { error: "Server is starting" }while the server boots. GET /api/infoalso returnsdemoLogin?: { username, password }in demo mode. Through a gateway (/s/<siteId>/api/info)setupRequiredis alwaysfalse,demoLoginis omitted (logins there are gateway accounts) andsiteIdis set.Recording.videoUrl,thumbUrl,MotionEvent.snapshotUrland the clipurlare paths (/api/...). Prefix them with the server base URL (e.g.https://gw/s/<siteId>) and append?token=.Camera.fields(brand fields, secrets masked as***) for edit forms; sending***back inPATCHkeeps the stored value.Camera.createdAt.Camera.capabilities.ptzPresets?: { id, name }[](ONVIF presets; present whenptzis true).GET /api/cameras/:id/ptz/presetsreturns{ presets: { token, name }[] }.Fieldmay carryhelp?anddefault?.POST /api/cameras/testalso accepts{ id, fields? }to test an existing camera with changed fields.GET /api/cameras/:id/ws?quality=WebSocket: go2rtc player protocol (MSE / WebRTC signalling / HLS / MJPEG), used by/player/:id.GET /api/cameras/:id/hls/<file>child playlists and segments oflive.m3u8(relative URLs, token appended).POST /api/cameras/:id/webrtcreturns{ type: 'answer', sdp }./player/:idquery:token,quality=hd|sd,muted=0|1(default 1),talk=1(start with microphone),mode=(e.g.mse),fit=cover,embed=app(no overlays),state=0. Inside a React Native WebView it posts{ type: 'state', state: 'loading'|'playing'|'error', mode?, error? }and{ type: 'talk', state: 'on'|'off'|'error', error? }viawindow.ReactNativeWebView.postMessage, and exposeswindow.OpenCCTV = { setMuted(b), setQuality('hd'|'sd'), talk(on): Promise<void> }(talk reconnects over WebRTC with the microphone; needs a camera with a backchannel, e.g. Tapo with cloud password).GET /api/timelinealso returnsday,tz,from,to.POST /api/clipsreturns{ url, expiresAt }; clips are kept 24 h, max 1 h long, from local footage.GET /api/recordings/:idreturns a singleRecording.GET /api/storagealso returnsgdrive: { deviceFlow, browserFlow, defaultBrowserClient }andrclone: boolean.StorageTarget.status.failedcounts uploads that gave up after 10 attempts.types[].fieldsdrive the add form.POST /api/storage/gdrive/startbody:{ name?, path?, retentionDays?, mode?: 'device'|'browser', clientId?, clientSecret? }. Device flow when the server has a TV client configured and noclientIdis given, otherwise the browser flow ({ flowId, authUrl, redirectUri, expiresAt }; redirect URI is<base>/api/storage/gdrive/callback). Third option: create agdrivetarget directly withconfig: { token: '<rclone authorize "drive" JSON>' }.POST /api/storage/targets/:id/retention(admin) runs remote and local retention now.GET /api/settingsgatewayalso hassiteId?anderror?.GETis allowed for viewers,PATCHadmin only.GET /api/systemalso returnspushDevices,demo.POST /api/push/registerremembers the base URL the app used; pushdata={ type: 'motion', cameraId, eventId, start, server, serverName }, title = camera name, body "Motion detected",channelId: 'motion',sound: 'default'.- WebSocket
/api/wsfirst sends{ type: 'hello', version, name }; admins also receive{ type: 'site', site }on gateways. - Pairing:
POST /api/auth/pairing-codealso returnsserver(the base URL in the link). Aviewerpairing creates a dedicated viewer user for the device; anadminpairing signs the device in as the admin who created the code. Pairing codes are created on the server or gateway the app will talk to (not through/s/<siteId>). - Gateway:
POST /api/sitesreturns{ id, name, linkCode, expiresAt }(link codes are valid 24 h, single use);POST /api/sites/:id/link-codecreates a new one;GET /api/sitesitems also havelinked.POST /api/gateway/claim(public, used by the home server){ linkCode, name?, version? }→{ siteId, secret, name }. - Under
/s/<siteId>/,api/auth/login|pair|me|logoutare answered by the gateway itself (gateway accounts),api/infois public, everything else needs a gateway token and is forwarded with the gateway user's role.
Binary WebSocket frames: [type u8][stream u32 BE][payload]. The home server connects with Authorization: Bearer <site secret> and X-OpenCCTV-Site: <siteId>. Types: 1 HELLO {version,cameras,name}, 2 REQ {method,path,headers,role,user,base}, 3 REQ_DATA, 4 REQ_END, 5 RES {status,headers}, 6 RES_DATA (≤ 64 KiB), 7 RES_END, 8 CANCEL, 9 CREDIT (u32 bytes; 512 KiB initial window per response stream), 10 WS_OPEN {path,headers,protocols,role,user,base}, 11 WS_ACCEPT, 12 WS_TEXT, 13 WS_BINARY, 14 WS_CLOSE {code,reason}, 15 PING, 16 PONG, 17 ERROR {message}. Gateway-initiated streams use odd ids.