CronySoft NVR — Backoffice Integration API

CronySoft NVR — Backoffice Integration API

This is the developer reference for backoffice / POS / BI systems that
need to pull video clips from a CronySoft NVR programmatically — for
example, embedding the "video for this transaction" tile next to a
receipt in a POS reconciliation tool.

The API is already shipped in production at app.cronynvr.com.
No special build / SDK; standard HTTPS + JSON.

URL versioning. All endpoints below live under the /api/v1/
prefix. We commit to keeping /api/v1/* shape-stable forever — once
you integrate, you never have to redeploy because the cloud changed.
Breaking changes ship under /api/v2/* alongside /api/v1/*. The
un-versioned /api/clips/* URLs are still served as aliases for
legacy integrations but new code should always target /api/v1/.

Quick answer to "do we have an API key for the cloud account":
Yes — per store, not per account. Each store you want to integrate
with has its own API keys under Store Settings → 🔑 API Access.
Keys never grant cross-store visibility, so a compromised key only
exposes one store's footage.


1. The shape of an integration

Three calls cover 99 % of backoffice use cases:

┌──────────────────┐       1. POST /api/v1/clips/issue            ┌─────────────────┐
│  Your backoffice │ ────────────────────────────────────────► │  app.cronynvr   │
│  (Java / C# /    │       X-DVRCloud-Key: csk_…               │                 │
│   Node / Python) │                                            │                 │
│                  │ ◄──────────────────────────────────────── │                 │
│                  │   { playbackUrl, downloadUrl, snapshotUrl} │                 │
└────────┬─────────┘                                            └─────────────────┘
         │ 2. Embed playbackUrl in an <video> / iframe
         │    OR redirect user to downloadUrl
         ▼
   ┌────────────────────────────────┐
   │  Browser plays the clip        │
   │  (HLS via .m3u8, MP4, or JPG)  │
   └────────────────────────────────┘
  1. Mint a clip token with the time window you want (POST /api/v1/clips/issue).
  2. Embed the returned URL in your UI as <video src>, <a href>, or <img src>.
  3. The browser fetches it. The token in the URL is the credential — anyone holding
    the URL can play the clip until it expires (default 30 min, configurable up to 24 h).

You never deal with raw RTSP, ffmpeg, port forwarding, or the DVR's IP.
The cloud assembles the clip on demand.


2. Get an API key

The clip endpoint accepts either:

  • a store-scoped API key in an X-DVRCloud-Key header (for server-to-server
    integrations — POS, BI, backoffice tools), or
  • a user JWT in Authorization: Bearer … (used by our own portal pages).

For backoffice integrations always use the API key path.

Create a key

  1. Sign in to https://app.cronynvr.com.
  2. Stores → click the store you want to integrate → ⚙ Settings🔑 API Access tab.
  3. Click + New API key. Give it a name (POS reconciliation, BI nightly, etc.)
    and pick its scopes (currently just clips:read).
  4. Copy the key NOW. It's shown once; the cloud stores only a hash
    from this point on. If you lose it, revoke + recreate.

The key format is csk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxx:

  • csk_ — fixed prefix so you can spot leaked keys in code review / log files.
  • The next 8 chars are a public key prefix — shown in the portal so you can
    identify a leaked key without the secret.
  • The remaining chars are the secret. Treat like a password.

Confirm the key works

curl -H "X-DVRCloud-Key: csk_..." \
     https://app.cronynvr.com/api/v1/clips/whoami

Response:

{
  "storeId":      "11111111-2222-3333-4444-555555555555",
  "storeName":    "Main Street Coffee",
  "keyName":      "POS reconciliation",
  "keyPrefix":    "csk_a1b2c3d4",
  "scopes":       "clips:read",
  "keyCreatedAt": "2026-05-17T10:23:00Z"
}

The storeId is the only one this key can act on; you don't need to
configure it elsewhere — just pass it to /api/v1/clips/issue.


3. List cameras — GET /api/v1/clips/cameras

Call this on app boot. The integration shouldn't maintain a hardcoded
camera list; people add and rename cameras through the cloud portal
and your tool should reflect that automatically.

Request

curl -H "X-DVRCloud-Key: csk_..." \
     https://app.cronynvr.com/api/v1/clips/cameras

Response

[
  {
    "name":         "cam_01",
    "cameraNumber": 1,
    "displayName":  "1 · Register",
    "zone":         "Register",
    "manufacturer": "Amcrest",
    "model":        "IP4M-1041",
    "isOnline":     true,
    "snapshotUrl":  "https://app.cronynvr.com/api/v1/clips/cameras/cam_01/snapshot.jpg?store=…&exp=1716046800&sig=…"
  },
  {
    "name":         "cam_02",
    "cameraNumber": 2,
    "displayName":  "2 · Drive-thru window",
    "zone":         "Drive-thru window",
    "manufacturer": "Hikvision",
    "model":        "DS-2CD2143G2",
    "isOnline":     true,
    "snapshotUrl":  "..."
  }
]

Field reference:

Field Notes
name Internal Frigate name. This is what you pass to /api/v1/clips/issue as cameraName.
cameraNumber 1-based channel number the customer sees in their dashboard.
displayName Operator-set friendly name combining number + zone. Show this to your users.
zone Operator-set free-text zone label (Register, Drive-thru, etc.) Optional.
manufacturer / model Camera hardware info. Useful for diagnostics.
isOnline Whether the camera is responding to the NVR right now.
snapshotUrl Pre-signed URL of the current frame (≈ now ± 1 s). Droppable into <img src>. Expires 15 minutes after the list call — re-fetch /cameras to get fresh ones.

Snapshot URLs

The snapshotUrl is HMAC-signed in the URL itself (no header needed),
so you can use it directly in <img> tags:

<img src="https://app.cronynvr.com/api/v1/clips/cameras/cam_01/snapshot.jpg?store=…&exp=…&sig=…">
  • 15-minute expiry. Re-call /api/v1/clips/cameras to refresh.
  • Snapshot-only. This URL can't be repurposed to pull video or expose other cameras — the signature is tied to (storeId, cameraName, expiry).
  • No audit-row per fetch. The /cameras call is the audited event; individual <img> loads are cheap.

Python example: thumbnail grid on app boot

import requests, os
API = "https://app.cronynvr.com"
KEY = os.environ["CRONYNVR_API_KEY"]

cameras = requests.get(f"{API}/api/v1/clips/cameras",
                       headers={"X-DVRCloud-Key": KEY}, timeout=8).json()
for c in cameras:
    print(f"{c['displayName']}  {'✓' if c['isOnline'] else '✗'}  {c['snapshotUrl']}")

Node example: render a <img> per camera

const r = await fetch("https://app.cronynvr.com/api/v1/clips/cameras",
  { headers: { "X-DVRCloud-Key": process.env.CRONYNVR_API_KEY! } });
const cameras = await r.json();
document.getElementById("grid").innerHTML = cameras.map(c =>
  `<figure>
     <img src="${c.snapshotUrl}" alt="${c.displayName}">
     <figcaption>${c.displayName} ${c.isOnline ? "" : " · offline"}</figcaption>
   </figure>`).join("");

4. Issue a clip — POST /api/v1/clips/issue

Endpoint

POST https://app.cronynvr.com/api/v1/clips/issue

Headers

Header Value
X-DVRCloud-Key Your csk_… API key
Content-Type application/json
Idempotency-Key (optional) String ≤ 120 chars. If you send the same key on a second call within 1 hour, you get the same token row back (same URLs, same expiry) instead of a fresh one. Use your own request id, receipt id, or a UUID. Mirrors Stripe's pattern — solves "user double-clicked" without polluting the audit log.

Request body

{
  "storeId":     "11111111-2222-3333-4444-555555555555",
  "cameraName":  "cam_01",
  "fromUtc":     "2026-05-17T14:23:10Z",
  "toUtc":       "2026-05-17T14:24:40Z",
  "ttlMinutes":  60,
  "receiptId":   "TXN-4471",
  "reasonCode":  "pos_reconciliation",
  "userId":      "emp-7281",
  "userName":    "Sarah Chen"
}

Field reference:

Field Required Notes
storeId UUID. Must match the key's store (the call 403s otherwise).
cameraName Internal camera name from Frigate (e.g. cam_01, register_front). List via the portal's Stores → DVR → Cameras tab, or via /api/stores/{id}/cameras.
fromUtc ISO 8601 with explicit Z or +00:00. The clip starts here.
toUtc ISO 8601, must be strictly after fromUtc and ≤ 6 hours later.
ttlMinutes How long the returned URL is playable. Default 30, max 1440 (24 h), min 1.
receiptId Free-text id from your system, written to our audit log. Lets you answer "show me every video viewed for transaction X."
reasonCode Free-text reason code (e.g. theft_review, dispute). Audit field.
userId Free-text id of the user-of-record in YOUR system. Not validated; used for audit. Max 80 chars.
userName Free-text display name. Max 200 chars.

userId / userName matter when an auditor later asks "which of your employees pulled this clip?" — your user model and ours don't share keys, so pass through whatever your IdP uses. Both fields are surfaced in the cloud audit page.

Response

{
  "token":       "ct_n8jq…",
  "expiresUtc":  "2026-05-17T15:23:40Z",
  "playbackUrl": "https://app.cronynvr.com/api/v1/clips/play/ct_n8jq…/master.m3u8",
  "downloadUrl": "https://app.cronynvr.com/api/v1/clips/play/ct_n8jq…/download.mp4",
  "snapshotUrl": "https://app.cronynvr.com/api/v1/clips/play/ct_n8jq…/snapshot.jpg",
  "fromUtc":     "2026-05-17T14:23:10Z",
  "toUtc":       "2026-05-17T14:24:40Z",
  "cameraName":  "cam_01"
}
  • playbackUrl — HLS manifest. Drop into any browser <video> with hls.js / Safari / iOS native. Lowest latency.
  • downloadUrl — server-muxed MP4. Use for "Download evidence" buttons. The browser's download manager handles the rest.
  • snapshotUrl — JPEG at the fromUtc instant. Cheap thumbnail.
  • token — already baked into the URLs; you usually don't need it separately.
  • expiresUtc — after this the URLs return 410. Re-issue with another POST /issue.

Error responses

HTTP Body What it means
400 {"error": "missing_camera"} No cameraName
400 {"error": "to_must_be_after_from"} toUtc <= fromUtc
400 {"error": "window_too_large_max_6_hours"} toUtc - fromUtc > 6 h. Issue multiple clips for longer windows.
401 {"error": "invalid_api_key"} Bad / revoked key
403 (empty) Key doesn't belong to that storeId
404 {"error": "dvr_not_paired"} Store has no DVR yet
404 {"error": "camera_not_found"} Camera name not on that DVR
429 (empty) Rate-limited (see below)

5. Rate limits

POST /api/v1/clips/issue is 30 requests / minute / source IP, sliding window.
That's plenty for normal POS reconciliation workloads (one clip per investigated
transaction); a bulk-export script should pace itself.

Beyond rate-limit, individual stores have a plan-level cap on concurrent active tokens that you'll only hit if you mint thousands without consuming them. Tokens older than their TTL are reaped automatically.


6. Code samples

curl

curl -X POST https://app.cronynvr.com/api/v1/clips/issue \
  -H "X-DVRCloud-Key: csk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "storeId":    "11111111-2222-3333-4444-555555555555",
    "cameraName": "cam_01",
    "fromUtc":    "2026-05-17T14:23:10Z",
    "toUtc":      "2026-05-17T14:24:40Z",
    "ttlMinutes": 60,
    "receiptId":  "TXN-4471",
    "userId":     "emp-7281",
    "userName":   "Sarah Chen"
  }'

Python (requests)

import requests, os
from datetime import datetime, timezone, timedelta

API   = "https://app.cronynvr.com"
KEY   = os.environ["CRONYNVR_API_KEY"]                 # csk_...
STORE = "11111111-2222-3333-4444-555555555555"

def clip_for_receipt(camera, receipt_ts_utc: datetime, receipt_id: str,
                     emp_id: str, emp_name: str,
                     before=20, after=70):
    """Issue a clip running `before` seconds before to `after` after the
    POS event. Returns the dict the cloud sent back."""
    resp = requests.post(f"{API}/api/v1/clips/issue",
        headers={"X-DVRCloud-Key": KEY},
        json={
            "storeId":    STORE,
            "cameraName": camera,
            "fromUtc":    (receipt_ts_utc - timedelta(seconds=before)).isoformat().replace("+00:00", "Z"),
            "toUtc":      (receipt_ts_utc + timedelta(seconds=after)).isoformat().replace("+00:00", "Z"),
            "ttlMinutes": 60,
            "receiptId":  receipt_id,
            "reasonCode": "pos_reconciliation",
            "userId":     emp_id,
            "userName":   emp_name,
        }, timeout=10)
    resp.raise_for_status()
    return resp.json()

clip = clip_for_receipt("cam_01",
                        datetime(2026, 5, 17, 14, 24, 0, tzinfo=timezone.utc),
                        "TXN-4471", "emp-7281", "Sarah Chen")
print("Watch:",    clip["playbackUrl"])
print("Download:", clip["downloadUrl"])

Node / TypeScript (fetch)

type ClipResponse = {
  token: string; expiresUtc: string;
  playbackUrl: string; downloadUrl: string; snapshotUrl: string;
};

async function issueClip(opts: {
  receiptUtc: Date; camera: string; receiptId: string;
  empId: string; empName: string;
}): Promise<ClipResponse> {
  const r = await fetch("https://app.cronynvr.com/api/v1/clips/issue", {
    method: "POST",
    headers: {
      "X-DVRCloud-Key": process.env.CRONYNVR_API_KEY!,
      "Content-Type":   "application/json",
    },
    body: JSON.stringify({
      storeId:    "11111111-2222-3333-4444-555555555555",
      cameraName: opts.camera,
      fromUtc:    new Date(opts.receiptUtc.getTime() - 20_000).toISOString(),
      toUtc:      new Date(opts.receiptUtc.getTime() + 70_000).toISOString(),
      ttlMinutes: 60,
      receiptId:  opts.receiptId,
      reasonCode: "pos_reconciliation",
      userId:     opts.empId,
      userName:   opts.empName,
    }),
  });
  if (!r.ok) throw new Error(`clip issue failed: ${r.status} ${await r.text()}`);
  return r.json() as Promise<ClipResponse>;
}

C# (.NET 8+)

using System.Net.Http.Json;

public record IssueClipRequest(
    Guid storeId, string cameraName,
    DateTime fromUtc, DateTime toUtc,
    int? ttlMinutes = null, string? receiptId = null,
    string? reasonCode = null, string? userId = null, string? userName = null);

public record ClipResponse(
    string token, DateTime expiresUtc,
    string playbackUrl, string downloadUrl, string snapshotUrl,
    DateTime fromUtc, DateTime toUtc, string cameraName);

public sealed class CronyNvrClient(string apiKey, HttpClient http) {
    public async Task<ClipResponse> IssueClipAsync(IssueClipRequest req, CancellationToken ct = default) {
        var msg = new HttpRequestMessage(HttpMethod.Post,
            "https://app.cronynvr.com/api/v1/clips/issue") {
            Content = JsonContent.Create(req),
        };
        msg.Headers.Add("X-DVRCloud-Key", apiKey);
        using var resp = await http.SendAsync(msg, ct);
        resp.EnsureSuccessStatusCode();
        return (await resp.Content.ReadFromJsonAsync<ClipResponse>(ct))!;
    }
}

PowerShell

$body = @{
    storeId    = '11111111-2222-3333-4444-555555555555'
    cameraName = 'cam_01'
    fromUtc    = '2026-05-17T14:23:10Z'
    toUtc      = '2026-05-17T14:24:40Z'
    ttlMinutes = 60
    receiptId  = 'TXN-4471'
} | ConvertTo-Json

Invoke-RestMethod -Method POST `
  -Uri https://app.cronynvr.com/api/v1/clips/issue `
  -Headers @{ 'X-DVRCloud-Key' = $env:CRONYNVR_API_KEY } `
  -ContentType 'application/json' `
  -Body $body

7. Embedding the playback

HLS in a browser

<video id="v" controls playsinline></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
  const v = document.getElementById('v');
  const url = "https://app.cronynvr.com/api/v1/clips/play/ct_n8jq…/master.m3u8";
  if (window.Hls && Hls.isSupported()) {
    const hls = new Hls(); hls.loadSource(url); hls.attachMedia(v);
  } else if (v.canPlayType('application/vnd.apple.mpegurl')) {
    v.src = url;     // iOS / Safari native
  }
</script>

MP4 download

<a href="…/download.mp4" download="receipt-4471.mp4">⬇ Download MP4</a>

The browser saves directly. The MP4 has burned-in metadata in the corner
(store + camera + timestamp + optional POS overlay), so it's
self-contained as evidence.

Snapshot thumbnail

<img src="…/snapshot.jpg" alt="receipt 4471 video frame">

Cheap. Useful for receipt-list rows that should preview the moment
without auto-playing video.


8. Best practices

  • Mint on demand, not in bulk. Cheaper than minting thousands of
    tokens "just in case" — they sit in the DB until they expire.
  • Pass receiptId / userId / userName on every call. The
    audit page is your friend when a dispute lands six months later.
  • Use HTTPS only. Tokens are URL credentials; don't put them in
    emails or HTTP referer fields.
  • Rotate keys when employees leave. Revoke from Store Settings →
    API Access; old key 401s on the next call.
  • Cache the whoami response for the lifetime of your process —
    it doesn't change unless someone rotates the key.
  • Bound your time windows. Max 6 hours per token; for nightly
    rollups, batch by hour.

9. Other endpoints

You now have everything for the standard "show me cameras + pull
video for a transaction" backoffice workflow with key-only auth:

Endpoint Purpose
GET /api/v1/clips/whoami Confirm the key works + discover its bound storeId
GET /api/v1/clips/cameras List the store's cameras + per-camera snapshot URL
POST /api/v1/clips/issue Mint a time-windowed clip (HLS + MP4 + JPG URLs)
GET /api/v1/clips/play/{token}/master.m3u8 HLS playback (opaque token in URL)
GET /api/v1/clips/play/{token}/download.mp4 MP4 download
GET /api/v1/clips/play/{token}/snapshot.jpg Single-frame snapshot at the clip midpoint
GET /api/v1/clips/cameras/{name}/snapshot.jpg?... Current-frame snapshot (signature-gated)

If you need multi-store discovery from a key-only context (a single
backoffice login pulling from N stores under one account), email
support@aliumtech.com — we can broaden the existing key scope model
rather than spinning up parallel auth.


10. Support + audit trail

  • Every clip issuance writes an audit row with the calling key id,
    store, camera, time window, requesting userId/userName, receiptId,
    reasonCode, and source IP. The store's Audit tab surfaces this.
  • Retention: clip-token rows (and the corresponding audit data)
    are kept for 90 days past their expiresUtc, then hard-deleted
    by the cloud's nightly reaper. If your compliance workflow needs
    longer retention, export the audit log from the portal periodically
    (Store Settings → Audit → Export CSV).
  • Issues: support@aliumtech.com — include the keyPrefix (the public
    half — csk_a1b2c3d4), the time, and the response body. We can
    trace it.
  • Status / outages: https://status.cronynvr.com.

All docs  ·  Suggest a correction