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) │
└────────────────────────────────┘
- Mint a clip token with the time window you want (POST
/api/v1/clips/issue). - Embed the returned URL in your UI as
<video src>,<a href>, or<img src>. - 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-Keyheader (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
- Sign in to
https://app.cronynvr.com. - Stores → click the store you want to integrate → ⚙ Settings → 🔑 API Access tab.
- Click + New API key. Give it a name (
POS reconciliation,BI nightly, etc.)
and pick its scopes (currently justclips:read). - 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/camerasto 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
/camerascall 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 thefromUtcinstant. 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 anotherPOST /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/userNameon 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
whoamiresponse 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 theirexpiresUtc, 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 thekeyPrefix(the public
half —csk_a1b2c3d4), the time, and the response body. We can
trace it. - Status / outages:
https://status.cronynvr.com.