Episodes through the API: create, annotate and search¶
An external system can store its processing result in an episode’s meta field. For example, an access control system links a recognized vehicle to a request and records its decision to open the gate. The example below saves request ID REQ-2026-001, then finds the episodes associated with it.
Before you start¶
Creating and updating requires management API write access on Optic central. Set the address and credentials, then the name of an existing stream. The viewer role and a play token allow reading only. The examples require Bash, curl and jq.
export CENTRAL_URL='https://central.example.com'
export API_LOGIN='admin'
export API_PASSWORD='your-password'
export STREAM='gate-entrance'
Enable episode retention in the stream or stream template DVR settings. Central retains episodes when the total retention is positive; video recording is optional. The next example creates an episode. For an existing episode, take its ID from the list and proceed to writing meta.
Create an episode¶
External systems call POST /central/api-v4/episodes directly: they do not need to locate the streamer running the stream. Create a closed custom episode with access_control as its original type.
The example sets opened_at and closed_at to the current Unix milliseconds, representing an instantaneous event. For real events, send their times on the video timeline; these may differ from the command execution time. Omit closed_at for an ongoing episode.
OPENED_AT=$(( $(date +%s) * 1000 ))
episode=$(jq -n --arg media "$STREAM" --argjson opened_at "$OPENED_AT" '
{
media: $media,
opened_at: $opened_at,
closed_at: $opened_at,
details: {type: "custom", original_type: "access_control"}
}
')
created=$(curl --fail-with-body --silent --show-error \
--user "$API_LOGIN:$API_PASSWORD" \
--header 'Content-Type: application/json' \
--data "$episode" \
"$CENTRAL_URL/central/api-v4/episodes")
EPISODE_ID=$(printf '%s\n' "$created" | jq -er '.episode_id')
export EPISODE_ID
printf '%s\n' "$created" | jq
The first request returns 201 and {"episode_id":"…"}. The commands save the returned ID in EPISODE_ID for subsequent PATCH and GET requests. The episode is immediately available in history and the stream’s Episodes tab in Optic.
Replaying the same media, opened_at and details.type returns 200 and the original ID without changing the episode. For custom, the key uses the literal type custom, not original_type. Resend the same JSON when retrying: computing a new OPENED_AT means a new event. POST does not edit an existing episode; use meta for additional data.
Creation errors:
- 404 — unknown stream.
- 409 — episode retention is disabled.
- 422 — invalid document, with field paths in the response.
- 413 — the complete POST body exceeds 32 MiB.
You can include original event data in payload. Use the separate meta field for later additions by external systems.
Save data¶
Send JSON Merge Patch to PATCH /central/api-v4/episodes/{episode_id}/meta. This example puts all integration data under access_control; other services can use their own keys.
curl --fail-with-body --user "$API_LOGIN:$API_PASSWORD" \
--request PATCH \
--header 'Content-Type: application/merge-patch+json' \
--data '{"access_control":{"request_id":"REQ-2026-001","decision":"allow"}}' \
"$CENTRAL_URL/central/api-v4/episodes/$EPISODE_ID/meta"
The 200 response contains the resulting meta value. It accepts arbitrary JSON. Objects merge recursively; arrays and scalars are replaced in full. Updating only decision preserves request_id. Concurrent changes to different keys are preserved together; the last committed write wins for the same key.
Read data¶
A regular episode GET returns meta; this command prints only that field:
curl --fail-with-body --user "$API_LOGIN:$API_PASSWORD" \
"$CENTRAL_URL/central/api-v4/episodes/$EPISODE_ID" | jq '.meta'
The field is also returned in episode lists and central playback API reads with a stream token. Until meta is set, its key is absent from the episode JSON. The Optic Episodes tab displays events and standard filters; read arbitrary meta fields through the API.
Update or delete data¶
To update data, send PATCH with the new values. To delete a key, set it to null. This request removes the decision and preserves the request ID:
curl --fail-with-body --user "$API_LOGIN:$API_PASSWORD" \
--request PATCH \
--header 'Content-Type: application/merge-patch+json' \
--data '{"access_control":{"decision":null}}' \
"$CENTRAL_URL/central/api-v4/episodes/$EPISODE_ID/meta"
To clear all of meta, send --data 'null' in the same request. The response is JSON null, and subsequent episode reads omit the meta key again. An empty object {} counts as a set value.
Find episodes by custom data¶
Central history supports a server-side meta filter: pass a JSON structure in the query string of GET /central/api-v4/episodes. The saved meta must contain it; extra fields are allowed. Every specified key must match at the correct nesting level. Filtering happens before the page limit and combines with standard filters using AND. The same filter is available in the central playback API. It is not available in the current streamer feed or the Optic console filter form.
For this example, choose a stream, event period and saved request ID. from and to are Unix milliseconds, with the upper bound excluded. Replace the example values with your own range.
export FROM_MS="$((OPENED_AT - 1000))"
export TO_MS="$((OPENED_AT + 60000))"
export REQUEST_ID='REQ-2026-001'
Request the first page:
curl --fail-with-body --user "$API_LOGIN:$API_PASSWORD" --get \
--data-urlencode "media=$STREAM" \
--data-urlencode "from=$FROM_MS" \
--data-urlencode "to=$TO_MS" \
--data-urlencode 'meta={"access_control":{"request_id":"REQ-2026-001"}}' \
"$CENTRAL_URL/central/api-v4/episodes" | jq
Save the following code as find-episodes.sh and run bash find-episodes.sh. It sends the filter to the server, visits result pages and prints one JSON object per matching episode. With curl --get, --data-urlencode goes in the query string; GET has no body.
set -euo pipefail
filter=$(jq -cn --arg request_id "$REQUEST_ID" \
'{"access_control":{"request_id":$request_id}}')
cursor=''
while :; do
query=(--data-urlencode "media=$STREAM"
--data-urlencode "from=$FROM_MS"
--data-urlencode "to=$TO_MS"
--data-urlencode "meta=$filter"
--data-urlencode 'limit=1000')
if [ -n "$cursor" ]; then
query+=(--data-urlencode "cursor=$cursor")
fi
page=$(curl --fail-with-body --silent --show-error \
--user "$API_LOGIN:$API_PASSWORD" --get "${query[@]}" \
"$CENTRAL_URL/central/api-v4/episodes")
printf '%s\n' "$page" | jq -c \
'.episodes[] | {episode_id, media, opened_at, meta}'
cursor=$(printf '%s\n' "$page" | jq -r '.next_cursor // empty')
[ -n "$cursor" ] || break
done
To find decisions to open the gate, use meta={"access_control":{"decision":"allow"}}. To check both the request ID and decision, specify both keys: meta={"access_control":{"request_id":"REQ-2026-001","decision":"allow"}}.
JSON types matter: the string "123" differs from the number 123. Arrays match the presence of required elements: {"tags":["staff","delivery"]} matches {"tags":["delivery","staff","urgent"]}. Element order and duplicates do not matter; a scalar can also match an array element. This is containment, rather than whole-array equality.
Episodes without meta never match a meta filter. An empty object {} matches object-valued meta, including nonempty objects; the null filter does not mean the field is absent. Invalid JSON or U+0000 in the query returns 400. There is no separate syntax for value ranges or OR.
Follow next_cursor while preserving the original JSON filter and other parameters. An empty page with a cursor does not end the search: it may indicate a skipped oversized record. offset is unsupported. History can change during traversal and is not a fixed snapshot. PATCH meta does not change updated_at: sorting by that timestamp cannot track changes to custom data.
Retention and errors¶
Harvesting from a streamer and replaying the creation POST preserve meta. PATCH does not change the source payload, event timestamps or retention deadline. Additional data is deleted with the episode; writing meta cannot extend its lifetime.
- 404 — the episode was not found or has already been deleted.
- 413 — the request body or resulting serialized
metaexceeds 32 MiB. - 422 — invalid JSON or U+0000; the response contains a structured error.
A rejected request preserves the previous meta value.