{"components":{"responses":{"NotFound":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"No such camera in this tenant."}},"schemas":{"Alert":{"properties":{"camera_name":{"type":"string"},"classified":{"type":"boolean"},"confidence":{"type":"number"},"cost_usd":{"type":"number"},"evidence_urls":{"description":"Full frame then crop. Signed, valid 30 minutes.","items":{"type":"string"},"type":"array"},"id":{"type":"string"},"matched":{"description":"False for a near-miss, returned only with `all=true`.","type":"boolean"},"objects":{"items":{"$ref":"#/components/schemas/Detection"},"type":"array"},"resolved_tier":{"description":"1 means free predicates decided it; 2 means a vision model was asked.","type":"integer"},"rule_name":{"type":"string"},"started_at":{"description":"When the alert fired. This is the field to filter and sort on; there is no `timestamp`.","format":"date-time","type":"string"},"tier1_reason":{"description":"Why a near-miss was rejected.","type":"string"}},"type":"object"},"Camera":{"properties":{"bytes_used":{"type":"integer"},"detect_objects":{"description":"Whether this camera classifies what moves.","type":"boolean"},"enabled":{"type":"boolean"},"id":{"type":"string"},"measured_kbps_sub":{"description":"Measured from real footage. Prefer it over any declared bitrate, which is a ceiling and routinely overstates reality tenfold.","type":"integer"},"name":{"type":"string"},"not_recording_reason":{"description":"Present when a camera is not capturing, in words worth showing a person.","type":"string"},"recording_mode":{"enum":["continuous_main","continuous_sub_plus_event_main","event_only","edge_only"],"type":"string"},"retention_days":{"enum":[1,7,14,30,90],"type":"integer"}},"type":"object"},"Detection":{"description":"One recognised object. Coordinates are fractions of the frame, the same space zones are drawn in.","properties":{"confidence":{"example":0.71,"type":"number"},"label":{"example":"person","type":"string"},"x1":{"type":"number"},"x2":{"type":"number"},"y1":{"type":"number"},"y2":{"type":"number"}},"type":"object"},"Error":{"properties":{"detail":{"type":"string"},"error":{"type":"string"}},"type":"object"},"Observation":{"description":"One motion event, with what was recognised in it.","properties":{"camera_id":{"type":"string"},"camera_name":{"type":"string"},"classified":{"description":"Whether a detector examined this event. When false, an empty `objects` means nobody looked — not that nothing was there.","type":"boolean"},"crop_url":{"description":"Signed image of what moved, valid 30 minutes.","type":"string"},"duration_s":{"type":"number"},"ended_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"objects":{"items":{"$ref":"#/components/schemas/Detection"},"type":"array"},"started_at":{"description":"When the motion began. The field to filter and sort on; there is no `timestamp`.","format":"date-time","type":"string"}},"type":"object"},"Observed":{"properties":{"at":{"format":"date-time","type":"string"},"camera":{"properties":{"id":{"type":"string"},"name":{"type":"string"}},"type":"object"},"classification_enabled":{"description":"Whether this camera classifies what moves at all.","type":"boolean"},"classified_events":{"description":"How many of them a detector examined.","type":"integer"},"motion_events":{"type":"integer"},"objects":{"description":"What was recognised, aggregated.","items":{"properties":{"count":{"type":"integer"},"label":{"type":"string"},"max_confidence":{"type":"number"}},"type":"object"},"type":"array"},"observations":{"items":{"$ref":"#/components/schemas/Observation"},"type":"array"},"recorded":{"description":"Whether footage exists for this instant. False means absence of evidence, not evidence of absence.","type":"boolean"},"summary":{"description":"The finding in one sentence, including its limits. Prefer quoting this over assembling your own.","type":"string"},"window_s":{"type":"integer"}},"type":"object"}},"securitySchemes":{"apiKey":{"description":"An API key, created under Settings. Send as `Authorization: Bearer \u003ckey\u003e`. Keys are per tenant and carry a role.","scheme":"bearer","type":"http"}}},"info":{"description":"A video management system, described for programs rather than people.\n\nTwo ideas shape this API and are worth knowing before calling it:\n\n**Absence of evidence is reported as absence of evidence.** \"No person was detected\" means three different things depending on whether anything was recorded, whether object classification is switched on for that camera, and whether a detector examined those particular events. Responses distinguish all three, and carry a plain-language `summary` saying which applies. Do not report a confident negative without checking `recorded` and `classification_enabled`.\n\n**Video never passes through this API.** Playback is a signed URL to object storage; live is a WebRTC endpoint. Ask for a grant, hand the URL to a player.\n\nTimes are UTC. Every endpoint accepts RFC3339 or epoch milliseconds where a time is expected.\n\n**These are camera events.** Alerts here come from camera rules — motion, objects, zones, schedules. They are not alarms from access control, intrusion panels or other systems. If you also have tools for those, do not treat the two as the same list: cross-referencing them is useful, conflating them is not.\n\n**History starts when this deployment did.** An empty result for a range before that means the system was not recording then, which is not the same as nothing happening.","summary":"Ask what a camera saw, and watch it.","title":"FluidCloud VMS","version":"1.0.0"},"openapi":"3.1.0","paths":{"/v1/alert-destinations":{"get":{"operationId":"listAlertDestinations","responses":{"200":{"description":"Configured destinations."}},"summary":"Where alerts are sent","tags":["Alerts"],"x-openai-isConsequential":false},"post":{"description":"Registers a webhook. Every matched alert is POSTed as signed JSON.\n\nThe response contains a signing secret, **shown once**. Each delivery carries `webhook-id`, `webhook-timestamp` and `webhook-signature` headers; the signature is HMAC-SHA256 over `\"\u003cid\u003e.\u003ctimestamp\u003e.\u003cbody\u003e\"`. Verify it and reject anything whose timestamp is more than a few minutes old.\n\nReturn any 2xx to accept. Failures retry with backoff for about two hours. `webhook-id` is the alert id — use it for idempotency, because a receiver that accepts but fails to reply will see the alert again.","operationId":"createAlertDestination","requestBody":{"content":{"application/json":{"schema":{"properties":{"headers":{"additionalProperties":{"type":"string"},"description":"Extra headers, for a receiver needing its own auth.","type":"object"},"name":{"type":"string"},"url":{"description":"HTTPS endpoint to POST to.","type":"string"}},"required":["url"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"secret":{"description":"Shown once. Signs every payload.","type":"string"}},"type":"object"}}},"description":"Created. Save the secret."}},"summary":"Be told when an alert fires","tags":["Alerts"]}},"/v1/alerts":{"get":{"description":"Alerts raised by **FluidCloud VMS camera rules**. These are camera events — motion matching a rule — and are unrelated to alarms from access control, intrusion panels or any other system. If a question is about doors, badges or panels, this is the wrong tool.\n\n**The time field is `started_at`** (RFC3339, UTC). There is no `timestamp` field.\n\nFilter with `from` and `to` rather than fetching everything and filtering yourself. History only goes back to when this deployment began recording, so an empty result for an older range means the system was not running then — not that nothing happened.","operationId":"listAlerts","parameters":[{"description":"Earliest alert to return. RFC3339 or epoch milliseconds.","example":"2026-09-26T00:00:00Z","in":"query","name":"from","schema":{"type":"string"}},{"description":"Latest alert to return. RFC3339 or epoch milliseconds.","in":"query","name":"to","schema":{"type":"string"}},{"in":"query","name":"limit","schema":{"default":50,"maximum":200,"type":"integer"}},{"description":"Include near-misses: events a rule considered and rejected. Use this to explain why something did not alert.","in":"query","name":"all","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"alerts":{"items":{"$ref":"#/components/schemas/Alert"},"type":"array"}},"type":"object"}}},"description":"Recent alerts."}},"summary":"Camera alerts from FluidCloud VMS","tags":["Alerts"],"x-openai-isConsequential":false}},"/v1/bridges":{"get":{"description":"The on-site boxes: whether each is connected, what it is costing its host, and whether object detection is working there.","operationId":"listBridges","responses":{"200":{"description":"Bridges."}},"summary":"Edge health","tags":["Cameras"],"x-openai-isConsequential":false}},"/v1/cameras":{"get":{"description":"Every camera, with health, recording mode, storage use and whether object classification is on.","operationId":"listCameras","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"cameras":{"items":{"$ref":"#/components/schemas/Camera"},"type":"array"}},"type":"object"}}},"description":"Cameras."}},"summary":"The fleet","tags":["Cameras"],"x-openai-isConsequential":false}},"/v1/cameras/{id}/coverage":{"get":{"description":"The hours this camera actually recorded. Ask this before requesting playback for a time — an empty window is otherwise indistinguishable from a fault. Hours are UTC and listed per stream; an event-only camera records its main stream only, and a listed hour holds only the minutes that had motion, not the whole hour.","operationId":"getCoverage","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"bytes":{"type":"integer"},"earliest":{"format":"date-time","type":"string"},"hours":{"items":{"properties":{"bytes":{"type":"integer"},"hour":{"format":"date-time","type":"string"},"stream":{"enum":["main","sub"],"type":"string"}},"type":"object"},"type":"array"},"latest":{"format":"date-time","type":"string"}},"type":"object"}}},"description":"Hours with footage."}},"summary":"What footage exists","tags":["Cameras"],"x-openai-isConsequential":false}},"/v1/cameras/{id}/embed":{"post":{"description":"Returns URLs a video player can use. **This handles both recorded playback and live** — pass `from` for a past moment, omit it for live.\n\nCreates nothing and changes nothing. It mints a short-lived, read-only grant for one camera; it is a POST only because it takes a body.\n\n`hls_url` comes back ready to play. Do not append anything to it.\n\n**Call this from a server, never a browser** — it is what lets a dashboard show video without holding an API key. The token names one camera, expires in minutes, and is useless against the rest of this API.\n\nCheck `has_footage` before showing the URL to someone: it is false when nothing was recorded at that moment, and the player would otherwise sit on a blank screen looking broken. `getCoverage` says what a camera actually has.\n\n**Do not choose a stream unless you have a reason.** Which one holds a given moment depends on the camera's recording mode — an event-only camera records only its main stream — so the default is corrected automatically when the requested one has nothing at that time. The response says which was used in `stream`, and why in `stream_note`.\n\nThe URL is an HLS playlist. Players and browsers that support HLS play it directly; it is not a file to download.\n\n**Live requests return two URLs, and they are not interchangeable.** `whep_url` is WebRTC signalling — a client POSTs an SDP offer to it. It is not a link a player or a browser can open, and offering it to a person gives them something that looks like video and is not. `hls_url` covers the last five minutes and plays in anything that speaks HLS, lagging real time by roughly a minute.\n\n**Which to use depends on the player, not on the network.** HLS is plain HTTPS and works anywhere HTTPS does, including networks that block UDP; it simply lags about a minute. WebRTC is sub-second but needs a real WebRTC client and the relay credentials in `ice_servers` — pass those to `RTCPeerConnection`, or the connection silently never forms on a network that blocks UDP.\n\nA player that speaks WebRTC should prefer `whep_url` for live. Anything else, and anyone being handed a link, wants `hls_url`.\n\n**Live is published on demand**, so this call asks the bridge to start pushing the camera and then waits until it has — typically two to five seconds. When it returns, `whep_url` is ready to negotiate against. Check `live_ready`: false means the camera did not start, and `whep_url` will not work yet.\n\nCall this endpoint again every 15 seconds while watching, or publishing stops.\n\n**A live request returns both on purpose, and they answer different questions.** `hls_url` is the last five minutes — what led up to now, playable in anything, about a minute behind. `whep_url` is this instant, sub-second, and needs a WebRTC client plus the `ice_servers` from the same response.\n\nOffering both is usually the right answer to \"show me that camera\": someone asking almost always wants to know what just happened as well as what is happening. Lead with whichever the viewer can actually play.\n\n**The time may move.** If nothing was recorded at the moment requested, the grant is built for the nearest footage within six hours instead — on whichever stream holds it — rather than returning a link that plays nothing. When that happens the response sets `snapped: true`, keeps `requested_from`/`requested_to`, and `snapped_note` says how far the window moved. Pass that on to the user: the video is real, but it is not the second they asked about.\n\n`has_footage: false` now means the camera recorded nothing within six hours on either stream. That, and only that, is grounds for saying there is no video.\n\n**A request with `from` returns recorded video only** — no `whep_url`, no live. That is deliberate: live is a side effect with a real cost on the customer's uplink and their relay bill. Pass `live: true` to get both.","operationId":"createPlaybackGrant","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"duration_s":{"description":"An alternative to `to`: how many seconds to play from `from`.","example":300,"type":"integer"},"from":{"description":"The moment to watch from, for recorded playback. RFC3339 or epoch milliseconds. Omit for live.","example":"2026-09-27T10:30:00-05:00","type":"string"},"live":{"description":"Whether to also start a live view. Defaults to false when `from` is given and true when it is not, which is almost always what you want. Starting live makes the on-site bridge push a second copy of the video and adds several seconds to this call, so do not ask for it alongside recorded playback unless the user wants both on screen at once.","type":"boolean"},"quality":{"default":"low","description":"For live only.","enum":["low","high"],"type":"string"},"stream":{"default":"sub","description":"`sub` is lower resolution and what continuous recording usually uses.","enum":["main","sub"],"type":"string"},"to":{"description":"End of the range. Defaults to five minutes after `from`.","type":"string"},"ttl_seconds":{"default":900,"maximum":3600,"type":"integer"}},"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"expires_at":{"format":"date-time","type":"string"},"from":{"format":"date-time","type":"string"},"has_footage":{"description":"Whether anything was actually recorded at `from`. When false the URL plays nothing.","type":"boolean"},"hls_url":{"description":"A playable HLS playlist. With `from`, the range requested; without it, the last five minutes. This is the URL to give someone.","type":"string"},"hls_url_note":{"type":"string"},"ice_servers":{"description":"STUN and TURN servers for RTCPeerConnection, with short-lived credentials. Required for whep_url to work anywhere that blocks UDP.","items":{"type":"object"},"type":"array"},"lease_seconds":{"description":"How long the publication lasts without being renewed. Call this endpoint again within it to keep watching.","type":"integer"},"live_ready":{"description":"Whether the camera is actually publishing. When false, whep_url will not connect yet — retry the call.","type":"boolean"},"stream":{"description":"The stream actually used, which may differ from the one requested.","type":"string"},"stream_note":{"description":"Present when the stream was changed, saying why.","type":"string"},"to":{"format":"date-time","type":"string"},"warning":{"description":"Present when there is no footage at that time.","type":"string"},"whep_url":{"description":"Live, via WebRTC/WHEP. A signalling endpoint, not a playable link — a client must POST an SDP offer. Do not hand this to a person.","type":"string"},"whep_url_note":{"description":"Says that 404 is expected briefly, and how long to retry.","type":"string"}},"type":"object"}}},"description":"Playback URLs, ready to use."}},"summary":"Get a URL to watch a camera, live or at a past time","tags":["Video"],"x-openai-isConsequential":false}},"/v1/cameras/{id}/observed":{"get":{"description":"The endpoint for correlating an event from another system — a forced door, a refused badge, a till opened — against what a camera saw.\n\nOne request answers it. **Read `recorded` and `classification_enabled` before trusting a negative**: `recorded: false` means nothing was being stored, and `classification_enabled: false` means motion was seen but nothing looked at what it was. The `summary` field states which case applies in words.\n\n**An exact timestamp usually misses.** An event-only camera records a few minutes an hour, so a time someone typed by hand nearly always falls in a gap, and `recorded: false` at 15:00 is entirely compatible with a person on camera at 15:17. When the window comes back empty the response carries a `nearest` object — `footage` (the closest recorded span, with its stream), `motion_before` and `motion_after` (the closest motion events either side, within six hours) — and `summary` states it in words.\n\nNever answer \"there is no video at that time\" from an empty window alone. Report what `nearest` holds, or ask again with a larger `window_s` (up to 3600). `window_s` defaults to 30 seconds, which is narrow on purpose; for a question about \"around 3 PM\" use 1800.\n\n**Times are UTC.** `t` is interpreted as UTC unless it carries an offset. A user saying \"10:30 this morning\" means their local time — convert it before asking, or pass the offset (`2026-09-27T10:30:00-05:00`).","operationId":"whatWasSeenAt","parameters":[{"description":"Camera id.","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"The moment to ask about. RFC3339 or epoch milliseconds.","example":"2026-09-27T18:05:18Z","in":"query","name":"t","required":true,"schema":{"type":"string"}},{"description":"How far either side of `t` to look.","in":"query","name":"window_s","schema":{"default":30,"maximum":3600,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Observed"}}},"description":"What the camera saw."},"404":{"$ref":"#/components/responses/NotFound"}},"summary":"Was anyone there at this moment?","tags":["Observations"],"x-openai-isConsequential":false}},"/v1/observations":{"get":{"description":"Searches every camera in the tenant unless `camera_id` is given. Use with `whatWasSeenAt` for questions of the form \"every forced-door event where a person was also in view\": search your own events, then ask this API about each timestamp.","operationId":"searchObservations","parameters":[{"description":"RFC3339 or epoch ms. Defaults to 24 hours ago.","in":"query","name":"from","schema":{"type":"string"}},{"description":"RFC3339 or epoch ms. Defaults to now.","in":"query","name":"to","schema":{"type":"string"}},{"description":"Comma-separated labels to require, e.g. `person,car`. Omit for all motion. Recognised: person, car, truck, bus, motorcycle, bicycle, dog, cat.","example":"person","in":"query","name":"objects","schema":{"type":"string"}},{"description":"Restrict to one camera.","in":"query","name":"camera_id","schema":{"type":"string"}},{"in":"query","name":"min_confidence","schema":{"default":0.4,"type":"number"}},{"in":"query","name":"limit","schema":{"default":100,"maximum":500,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"count":{"type":"integer"},"from":{"format":"date-time","type":"string"},"observations":{"items":{"$ref":"#/components/schemas/Observation"},"type":"array"},"summary":{"description":"The result in one sentence.","type":"string"},"to":{"format":"date-time","type":"string"}},"type":"object"}}},"description":"Matching motion events, newest first."}},"summary":"When was a person (or vehicle) seen, across cameras?","tags":["Observations"],"x-openai-isConsequential":false}},"/v1/search/visual":{"get":{"description":"Searches footage by **appearance**, using a description in ordinary words — \"person in a red jacket\", \"white van\", \"someone on a bicycle\". It works without anyone having labelled anything, because images and phrases are compared in a shared space.\n\n**This ranks, it does not identify.** Results are ordered by visual similarity and `score` is that similarity, not a probability. Unrelated images sit near 0.15 and a good match near 0.3, so a top result is the most likely candidate to look at — never a confirmed match, and never a person's identity. Present it that way.\n\n**Check `searchable` before reporting a negative.** `embedded_events` is how many events in the window could be searched at all. If it is 0, an empty result says nothing about what was there — visual search was off or unavailable when those events were recorded. `summary` states this in words.\n\nUse `whatWasSeenAt` for what was at a camera at a known time, and this when the time is what you are trying to find. Times are UTC.","operationId":"searchByDescription","parameters":[{"description":"What to look for, in plain words. Required.","in":"query","name":"q","schema":{"type":"string"}},{"description":"Restrict to one camera. Omitted searches every camera.","in":"query","name":"camera_id","schema":{"type":"string"}},{"description":"Start of the window, RFC3339. Defaults to seven days before `to`.","in":"query","name":"from","schema":{"type":"string"}},{"description":"End of the window, RFC3339. Defaults to now.","in":"query","name":"to","schema":{"type":"string"}},{"description":"Maximum results, 1-200. Defaults to 25.","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Matching events, best first"}},"summary":"Find recorded events that look like a description"}}},"security":[{"apiKey":[]}],"servers":[{"url":"https://fluidvms.fluidplatform.io"}],"tags":[{"description":"What the cameras saw. Start here.","name":"Observations"},{"description":"Rules that fired, and where they are delivered.","name":"Alerts"},{"description":"The fleet, and what footage exists.","name":"Cameras"},{"description":"Grants for playing recorded or live video.","name":"Video"}]}