Antare
Try it now

API guide

Pulling your day's events and transcripts

A working guide to exporting your organization's incident data over the Antare HTTP API with a service token: what you need before you start, what to call, in what order, and the behaviors that will silently give you the wrong data if you don't know about them.

01

Before you start

You need five things. The first three come from Antare or your organization's administrator, the fourth you create in the console, and the fifth you install.

You needWhat it looks likeWhere it comes from
Administrator access to your organization in the Antare consoleconsole.antare.ai, with Manage β†’ Services in the sidebar Your organization's administrator grants it
Your organization's id A UUID, e.g. 1f0c2a8e-7b3d-4e5f-9a6c-2d8e4b7f1a30 Supplied by Antare when your organization was set up. The console does not display it, and the name in console URLs (/o/your-org/…) is a slug, not the id. If you don't have it, ask your Antare account contact.
Your regional API hosthttps://api.eu-west-2.console.antare.ai (Europe) or https://api.us-east-1.console.antare.ai (US) Supplied with the id. If unsure, section 3 shows how to tell which is yours in one request.
A service token with View devices and View eventsst_ followed by 64 hex characters, shown once You create it in the console β€” section 2
Python 3.8 or newer, for the runnable clientpython3 --version No additional Python packages are required. Any language with an HTTP client works for the rest of the guide.
02

Authenticating

Integrations authenticate with a service token. A service is an identity in your organization that belongs to software rather than a person: it has a name, the roles that decide what it may read, and one token that expires on a date you choose. You create it in the console and present its token directly to the API.

Create a role for the integration

A service can do exactly what its roles allow, nothing more. The export in this guide needs two permissions: View devices (cameras, transcripts) and View events (incidents). Under Manage β†’ Roles, add a role β€” "API export", say β€” and enable just those two. The built-in Incident manager role also carries both, but with extras the export does not need; the default Member role carries neither, and every call returns 403.

Create the service

  1. Open Manage β†’ Services and click Add a service.
  2. Give it a name, pick the role you just made, and set Token expires. The expiry is required and cannot be extended later β€” when a token runs out you create a new service and switch to it.
  3. Click Add. The token is shown once. Copy it into wherever your deployment keeps secrets; the console keeps only its prefix (st_1a2b3c4d) so you can recognize it later.

Send the token as a bearer credential on every request:

Authorization: Bearer st_1a2b3c4d…

That is the whole handshake. There is no login step, no token exchange and no refresh token: the service token is the only credential, and it works until its expiry or until the service is deleted. To rotate, create a new service, switch your integration to its token, then delete the old service.

03

Your organization

Every data endpoint lives under {host}/orgs/{orgId}/…. The two regional hosts both exist for every customer, but only one holds your organization: the wrong host answers 404 {"message": "not found"} for your id, the right one answers your data. Put your three values in environment variables once, then a single request proves token, id and host together.

# macOS / Linux (bash or zsh) β€” replace the values with yours
export ANTARE_TOKEN=st_…
export ANTARE_ORG_ID=1f0c2a8e-7b3d-4e5f-9a6c-2d8e4b7f1a30
export ANTARE_API_HOST=https://api.eu-west-2.console.antare.ai

curl -sS -w '\nHTTP %{http_code}\n' \
  -H "Authorization: Bearer $ANTARE_TOKEN" \
  "$ANTARE_API_HOST/orgs/$ANTARE_ORG_ID/cameras?limit=1"
# Windows (PowerShell) β€” curl.exe is the real curl, not the PowerShell alias
$env:ANTARE_TOKEN    = "st_…"
$env:ANTARE_ORG_ID   = "1f0c2a8e-7b3d-4e5f-9a6c-2d8e4b7f1a30"
$env:ANTARE_API_HOST = "https://api.eu-west-2.console.antare.ai"

curl.exe -sS -w "`nHTTP %{http_code}`n" `
  -H "Authorization: Bearer $env:ANTARE_TOKEN" `
  "$env:ANTARE_API_HOST/orgs/$env:ANTARE_ORG_ID/cameras?limit=1"

The last line printed tells you where you stand:

HTTP 200   with {"data":[…],"meta":…} above it  β†’ token, id and host are all right
HTTP 404   {"message":"not found"}               β†’ wrong host for this org (try the other), or wrong id
HTTP 401   {"error":{"status":"unauthenticated"}} β†’ token wrong or expired
HTTP 403   "caller does not have permission…"     β†’ the service's role lacks View devices
04

The day's events

An incident is a detected event β€” a panic activation, an aggressive exchange, an unusual scene. It has a startedAt and an endedAt. This guide defines "the day's events" as the incidents that started on that UTC day. An incident that began at 23:58 and ran past midnight belongs to the day it started; one that began at 00:01 belongs to the next day. If you need everything that overlapped a day, widen the filter to endedAt > day start and startedAt < day end instead.

POST{host}/orgs/{orgId}/incidents/query

{
  "limit":  100,
  "sort":   [{ "startedAt": "desc" }, { "id": "asc" }],
  "filter": {
    "startedAt": { "gt": "2026-09-27T23:59:59Z", "lt": "2026-09-29T00:00:00Z" }
  }
}

Each incident carries its timing, a numeric severity, a type and source, the camera(s) it came from, and any button presses that belong to it:

{
  "id":           "a1b2c3d4-…",
  "startedAt":    "2026-09-28T12:53:42Z",
  "endedAt":      "2026-09-28T12:55:11Z",
  "severity":     1,
  "incidentType": "neutral",
  "source":       "uploadAnalysis",
  "title":        { "data": [{ "text": "Investigation of strange kitchen odor" }] },
  "summaryShort": { "data": [{ "text": "Multiple staff members investigate a persistent damp smell…" }] },
  "media":        [{ "id": "…", "camera": { "id": "c0ffee01-…" }, "liveCamera": null }],
  "buttonPushes": [{ "cameraId": "…", "buttonId": "…", "pushedAt": "2026-09-28T12:53:40Z" }],
  "labelIds":     [], "categoryIds": []
}
// Extracting camera ids from a day's incidents
const cameraIds = [...new Set(
  incidents.flatMap(i => (i.media || []).map(m =>
    (m.camera && m.camera.id) || (m.liveCamera && m.liveCamera.id)
  )).filter(Boolean)
)];

// Titles are structured, not plain text
const title = incident.title?.data?.[0]?.text ?? "";

One incident by id is GET {host}/orgs/{orgId}/incidents/{id}, and GET …/incidents/{id}/nearby returns other incidents close to it in time and place.

05

Button presses and SOS

A press that produced an incident is on that incident, under buttonPushes[], with the camera, the button and the device's own pushedAt time. The export in this guide takes the presses of the day's incidents as defined above β€” it does not filter presses by their own pushedAt, so a press at 23:59 that opened an incident which the API records as starting at 00:00 lands on the next day's export with its incident.

const presses = incidents.flatMap(i =>
  (i.buttonPushes || []).map(p => ({ incidentId: i.id, ...p })));
06

Transcripts

Transcripts are retrieved per camera, over a time range β€” not per incident. One call covers a whole day. There are two views of the same speech.

Every utterance

GET{host}/orgs/{orgId}/cameras/{cameraId}/transcript?start=&end=

GET …/cameras/c0ffee01-…/transcript
      ?start=2026-09-27T23:55:00Z
      &end=2026-09-29T00:05:00Z

{
  "data": {
    "utterances": [
      {
        "startedAt": "2026-09-28T10:50:52.1Z",
        "endedAt":   "2026-09-28T10:51:01.139Z",
        "speaker":   null,
        "fragment":  "And I guess that's",
        "startMs":   39352100,  // offset from the start you asked for β€” see below
        "endMs":     39361139
      }
    ]
  }
}

Utterances are returned in time order. speaker is a diarization index where the model could separate voices and null where it could not. Both bounds are required; without them the response is 200 with an empty list.

Speech grouped by tone

GET{host}/orgs/{orgId}/cameras/{cameraId}/metadata?start=&end=

The metadata endpoint returns regions: contiguous stretches of speech each typed by tone (aggressive, negative, positive, uninteresting), with the same utterances nested under richTranscript.utterances. It is the right call for a review queue that wants the heated exchanges first. It can return "regions": [] for a day on which transcript returns speech β€” regions come from a separate analysis step, utterances from transcription β€” so use transcript for completeness and metadata for ranking.

07

When to run, and what can change

Data for a day keeps arriving after the day ends. Body-worn cameras upload their footage when they are docked or otherwise have connectivity, and transcription and analysis run after the upload lands β€” so a shift that ends at 22:00 may not be fully transcribed until the cameras have docked overnight. Antare has not published a latency guarantee.

Results for a past day can also change after you first read them:

  • Late uploads add incidents and utterances to a day that already looked complete.
  • Incidents can be merged by operators; a merged incident carries the ids it absorbed in mergedIncidentIds, and the absorbed ones stop appearing.
  • Transcript segments can be superseded by improved ones for the same span, so an utterance you exported may later come back with different boundaries or text.

A reasonable starting policy is to export day D on the morning of D+1, after the fleet has docked, and to re-export the previous two or three days each run, replacing each day wholesale rather than upserting rows. That lookback is a starting point, not a guarantee: uploads can arrive later than that, and operators can merge or correct incidents at any time, so expect to re-export older days by hand when you learn of a late upload or a correction.

Incidents have a stable id, but an utterance does not: its boundaries can change when a segment is superseded, so a key built from camera id, startedAt and endedAt would leave the superseded row behind. Replacing the whole day avoids that. Rerunning a day is safe on the API side β€” every call here is a read.

08

A complete daily export

Putting it together: the incidents that started on the day, the presses that belong to them, and every utterance from every camera that day. Transcripts are pulled for all cameras, not just those that appear in an incident β€” most of a day's speech happens outside any incident. The runnable client in section 9 makes the same choices.

const HOST  = process.env.ANTARE_API_HOST;   // https://api.eu-west-2.console.antare.ai
const ORG   = process.env.ANTARE_ORG_ID;     // your organization's UUID
const TOKEN = process.env.ANTARE_TOKEN;      // st_…, from Manage β†’ Services
const day   = "2026-09-28";                  // a UTC day

const API = `${HOST}/orgs/${ORG}`;
const H   = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };

// fetch() does not throw on 401/403/404/500, and an error body has no `data`,
// so an unchecked `r.data?.utterances ?? []` would turn a failure into "no
// speech". Check the status and the shape on every response.
async function getJson(url, init) {
  const res = await fetch(url, init);
  const text = await res.text();
  if (!res.ok) throw new Error(`${res.status} from ${url}: ${text.slice(0, 300)}`);
  const body = JSON.parse(text);
  if (!("data" in body)) throw new Error(`unexpected response shape from ${url}`);
  return body;
}

// Incident filters use gt/lt (exclusive), so bracket the day from one second
// outside it and trim client-side. Transcript windows are inclusive and must
// contain the whole utterance, so pad those by five minutes and trim likewise.
const shift = (d, secs) => new Date(Date.parse(d + "T00:00:00Z") + secs * 1000)
                            .toISOString().replace(/\.\d+/, "");
const lo = shift(day, -1),   hi = shift(day, 86400);
const tLo = shift(day, -300), tHi = shift(day, 86400 + 300);

// List endpoints return at most 100 rows per call; meta.pagination.total is
// always present and drives the loop. An empty page before the total is
// reached is an error, not the end: never return a partial day silently.
async function fetchAll(url, body) {
  const rows = [];
  for (let offset = 0; ; ) {
    const r = await getJson(url, {
      method: "POST", headers: H,
      body: JSON.stringify({ ...body, limit: 100, offset })
    });
    const total = r.meta?.pagination?.total;
    if (total === undefined) throw new Error(`no meta.pagination.total from ${url}`);
    rows.push(...r.data);
    offset += r.data.length;
    if (offset >= total) return rows;
    if (!r.data.length) throw new Error(`${url}: got ${offset} of ${total} rows, then an empty page`);
  }
}

// 1 β€” incidents that started on the day (note gt/lt, not gte/lte)
const incidents = (await fetchAll(`${API}/incidents/query`, {
  sort: [{ startedAt: "desc" }, { id: "asc" }],
  filter: { startedAt: { gt: lo, lt: hi } }
})).filter(i => i.startedAt.startsWith(day));

// 2 β€” the presses that belong to those incidents
const presses = incidents.flatMap(i =>
  (i.buttonPushes || []).map(p => ({ incidentId: i.id, ...p })));

// 3 β€” every utterance from every camera, assigned to the day by startedAt
// An empty deletedAt filter includes cameras since removed; without it they are left out
const cameras = await fetchAll(`${API}/cameras/query`, { filter: { deletedAt: {} }, sort: [{ id: "asc" }] });
const transcripts = {};
for (const cam of cameras) {
  const url = `${API}/cameras/${cam.id}/transcript?start=${tLo}&end=${tHi}`;
  const r = await getJson(url, { headers: H });
  if (!Array.isArray(r.data.utterances)) throw new Error(`no utterances array from ${url}`);
  transcripts[cam.id] = r.data.utterances.filter(u => u.startedAt.startsWith(day));
}
09

A runnable client

A complete export in the Python standard library β€” no dependencies to install. It reads the token, organization id and host from the environment, runs unattended, and writes one CSV per data set into the directory you run it from.

  1. Copy the script below into a file named antare_export.py.
  2. Open a terminal in the folder you saved it to and set the three variables (replace the values with yours):
# macOS / Linux (bash or zsh)
export ANTARE_TOKEN=st_…
export ANTARE_ORG_ID=1f0c2a8e-7b3d-4e5f-9a6c-2d8e4b7f1a30
export ANTARE_API_HOST=https://api.eu-west-2.console.antare.ai
python3 antare_export.py 2026-09-28

# Windows (PowerShell)
$env:ANTARE_TOKEN    = "st_…"
$env:ANTARE_ORG_ID   = "1f0c2a8e-7b3d-4e5f-9a6c-2d8e4b7f1a30"
$env:ANTARE_API_HOST = "https://api.eu-west-2.console.antare.ai"
py antare_export.py 2026-09-28

The date is a UTC day; with no date it exports today so far. Progress goes to the terminal and three files appear in the directory you ran the command from. They are written as temporary files first: your existing CSVs are left untouched until every request and every temporary write has succeeded. Each file is then replaced individually; an interruption during that final step β€” a crash or a disk error between two renames β€” can still leave a mixed set, and a leftover .csv.tmp is the sign that a run did not finish. Running the same date again replaces all three, which is what you want after a re-export (section 7):

1f0c2a8e-7b3d-4e5f-9a6c-2d8e4b7f1a30 β€” 2026-09-28 (UTC)
  incidents        3 rows  incidents-2026-09-28.csv
  presses          0 rows  presses-2026-09-28.csv
  transcripts    862 rows  transcripts-2026-09-28.csv

The transcript file is one row per utterance, with the camera's id as well as its name so rows stay joinable if a camera is renamed:

camera_idcamerastarted_atended_atspeakertext
c0ffee01-…AB2C-DEF-GHJ2026-09-28T10:50:52.1Z2026-09-28T10:51:01.139ZAnd I guess that's
antare_export.py
#!/usr/bin/env python3
"""Export one UTC day of incidents, their button presses and all transcripts to CSV.

Standard library only (Python 3.8+). Reads a service token from the environment and
writes three CSVs into the current working directory, replacing them only once every
request has succeeded.

    ANTARE_TOKEN=st_... ANTARE_ORG_ID=... ANTARE_API_HOST=https://api.eu-west-2.console.antare.ai \
        python3 antare_export.py 2026-09-28
"""

import csv
import datetime as dt
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

TOKEN = os.environ.get("ANTARE_TOKEN", "")
ORG_ID = os.environ.get("ANTARE_ORG_ID", "")
HOST = os.environ.get("ANTARE_API_HOST", "https://api.eu-west-2.console.antare.ai").rstrip("/")
PAGE = 100                      # server-side cap on list endpoints
TIMEOUT = 60                    # seconds per request; transcripts for a long day can be large
PAD = dt.timedelta(minutes=5)   # transcript windows must contain whole utterances


# --- HTTP -----------------------------------------------------------------

def die(msg):
    print("error: " + msg, file=sys.stderr)
    sys.exit(1)


def request(method, url, params=None, body=None, attempt=0):
    """One call. Retries 429 (honoring Retry-After) and 5xx with backoff; everything
    else stops the run with a message that says what to fix."""
    full = url + ("?" + urllib.parse.urlencode(params) if params else "")
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(full, data=data, method=method, headers={
        "Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        detail = e.read()[:300].decode(errors="replace")
        if e.code == 401:
            die("401 unauthenticated β€” the service token was rejected. It may have\n"
                "       expired or the service may have been deleted; create a new\n"
                "       service under Manage β†’ Services in the console.")
        if e.code == 403:
            if "cannot access endpoint" in detail:
                die("403 β€” this endpoint is not open to service tokens:\n       " + full)
            die("403 forbidden β€” the service's roles lack permission for\n"
                "       %s\n       Give the service a role with View devices and "
                "View events." % full)
        if e.code == 404:
            die("404 not found β€” wrong org id, or the wrong regional host for it: " + full)
        if e.code in (429, 500, 502, 503, 504) and attempt < 5:
            wait = 2 ** attempt                               # 1, 2, 4, 8, 16 s
            if e.code == 429 and e.headers.get("Retry-After", "").isdigit():
                wait = max(wait, int(e.headers["Retry-After"]))
            print("  %d from server; retrying in %ds" % (e.code, wait), file=sys.stderr)
            time.sleep(wait)
            return request(method, url, params, body, attempt + 1)
        die("%d from %s\n       %s" % (e.code, full, detail))
    except (urllib.error.URLError, TimeoutError, OSError) as e:
        if attempt < 5:
            wait = 2 ** attempt
            print("  could not reach %s (%s); retrying in %ds" % (HOST, e, wait), file=sys.stderr)
            time.sleep(wait)
            return request(method, url, params, body, attempt + 1)
        die("could not reach %s after several attempts: %s\n"
            "       Check the host, your network and any proxy." % (HOST, e))


def paginate(url, body=None):
    """Walk an offset-paginated collection, 100 rows at a time.

    A full page is not the end of the data and the response never says it
    truncated; meta.pagination.total is always present and drives the loop.
    If it were missing, or a page came back empty early, stop loudly rather than
    export a partial day."""
    rows, offset = [], 0
    while True:
        if body is None:
            r = request("GET", url, params={"offset": offset, "limit": PAGE})
        else:
            r = request("POST", url, body=dict(body, limit=PAGE, offset=offset))
        page = r.get("data") or []
        rows += page
        total = (r.get("meta") or {}).get("pagination", {}).get("total")
        if total is None:
            die("response from %s had no meta.pagination.total; refusing to guess "
                "whether the export is complete" % url)
        offset += len(page)
        if offset >= total:
            return rows
        if not page:
            die("%s returned an empty page after %d of %d rows; refusing to export a "
                "partial day" % (url, offset, total))


# --- day boundaries -------------------------------------------------------

def iso(t):
    return t.strftime("%Y-%m-%dT%H:%M:%SZ")


def day_bounds(day):
    """(midnight, next midnight) of a UTC day as aware datetimes."""
    d = dt.date.fromisoformat(day)
    start = dt.datetime.combine(d, dt.time(), dt.timezone.utc)
    return start, start + dt.timedelta(days=1)


def on_day(rows, field, day):
    return [r for r in rows if (r.get(field) or "").startswith(day)]


# --- export ---------------------------------------------------------------

def main():
    if not TOKEN.startswith("st_"):
        die("set ANTARE_TOKEN to a service token from Manage β†’ Services in the console")
    if not ORG_ID:
        die("set ANTARE_ORG_ID to your organization's id (a UUID supplied by Antare)")
    day = sys.argv[1] if len(sys.argv) > 1 else str(dt.datetime.now(dt.timezone.utc).date())
    start, end = day_bounds(day)
    base = "%s/orgs/%s" % (HOST, ORG_ID)
    print("%s β€” %s (UTC)" % (ORG_ID, day), file=sys.stderr)

    staged = []

    def write(name, header, rows):
        """Write to a temporary file. publish() renames them all at the end, so a
        request failure leaves the previous export intact. The renames themselves
        happen one file at a time and are not atomic as a set."""
        path = "%s-%s.csv" % (name, day)
        with open(path + ".tmp", "w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(header)
            w.writerows(rows)
        staged.append((path + ".tmp", path, len(rows)))

    def publish():
        for tmp, path, n in staged:
            os.replace(tmp, path)
            print("  %-12s %5d rows  %s" % (path.split("-")[0], n, path), file=sys.stderr)

    # 1 β€” incidents that started on the day. gt/lt are exclusive, so the bounds
    #     sit one second outside the day and rows are trimmed to it.
    incidents = on_day(paginate(base + "/incidents/query", {
        "sort": [{"startedAt": "desc"}, {"id": "asc"}],
        "filter": {"startedAt": {"gt": iso(start - dt.timedelta(seconds=1)), "lt": iso(end)}},
    }), "startedAt", day)
    write("incidents",
          ["id", "started_at", "ended_at", "severity", "type", "title", "camera_ids"],
          [[i["id"], i.get("startedAt"), i.get("endedAt"), i.get("severity"),
            i.get("incidentType"), text_of(i.get("title")), " ".join(camera_ids(i))]
           for i in incidents])

    # 2 β€” the button presses that belong to those incidents (not every physical press)
    write("presses", ["incident_id", "camera_id", "button_id", "pushed_at"],
          [[i["id"], p.get("cameraId"), p.get("buttonId"), p.get("pushedAt")]
           for i in incidents for p in (i.get("buttonPushes") or [])])

    # 3 β€” every utterance from every camera, including cameras since deleted.
    #     The window is inclusive and must contain the whole utterance, so it is
    #     padded, and utterances are assigned to the day by startedAt.
    cameras = paginate(base + "/cameras/query", {"filter": {"deletedAt": {}}, "sort": [{"id": "asc"}]})
    rows = []
    for cam in cameras:
        data = request("GET", "%s/cameras/%s/transcript" % (base, cam["id"]),
                       params={"start": iso(start - PAD), "end": iso(end + PAD)})["data"]
        for u in on_day(data.get("utterances") or [], "startedAt", day):
            rows.append([cam["id"], cam.get("name"), u["startedAt"], u["endedAt"],
                         u.get("speaker"), (u.get("fragment") or "").replace("\n", " ").strip()])
    write("transcripts",
          ["camera_id", "camera", "started_at", "ended_at", "speaker", "text"], rows)
    publish()


def text_of(rich):
    """title and summaryShort are {"data": [{"text": ...}]}, not strings."""
    return "".join(part.get("text") or "" for part in (rich or {}).get("data") or [])


def camera_ids(incident):
    """A camera is nested under media[]; either key may be the populated one."""
    out = []
    for m in incident.get("media") or []:
        cam = m.get("camera") or m.get("liveCamera")
        if cam and cam.get("id"):
            out.append(cam["id"])
    return sorted(set(out))


if __name__ == "__main__":
    main()
10

Paging and filtering

List endpoints return at most 100 rows per call, whatever limit you ask for β€” this is the API's configured page cap, and a response of exactly 100 rows looks no different from a complete one. Page with offset. The response's meta.pagination block is part of every list response and total is the count across all pages; drive the loop from it.

A page parameter is not supported; passing one is silently ignored and you will re-read page zero forever.

Give every paged query a sort that ends in a unique field such as id. Without one the server applies no order, so successive pages can repeat some rows and skip others while the running count still reaches total.

{
  "data": [ … ],
  "meta": {
    "pagination": { "total": 27, "currentPage": 2, "totalPages": 3 }
  }
}

Comparison operators on a filter field:

OperatorStatusNotes
eqWorksExact match
neWorksNegated exact match
gt / ltWorksUse these for date ranges; both exclusive
gte / lteIgnoredSilently dropped β€” see Traps to avoid
11

Traps to avoid

12

Reference

Endpoints a service token can call

MethodPath (under {host}/orgs/{orgId})ReturnsPermission
GET/camerasCurrent camera fleet; removed cameras are left out (paged)View devices
POST/cameras/queryCamera fleet with a filter and sort in the body (paged like /incidents/query); filter deletedAt: {} to include removed camerasView devices
GET/cameras/{id}One cameraView devices
POST/incidents/queryDetected events, filtered and sortedView events
GET/incidents/{id}One incidentView events
GET/incidents/{id}/nearbyIncidents close in time and placeView events
GET/cameras/{id}/transcript?start=&end=Every utterance inside the windowView devices
GET/cameras/{id}/metadata?start=&end=Speech regions typed by toneView devices
GET/cameras/{id}/timeline?start=&end=Motion and people-count samplesView devices
GET/cameras/{id}/activity?start=&end=Activity samplesView devices
GET/cameras/{id}/playlist.m3u8Recorded footage playlistView devices

Not open to service tokens, and answering 403 "identity service cannot access endpoint …": /me, /cameraEventLogs, footage upload, live streaming, thumbnails and preview frames, reprocessing, and device control.

Transcript region types

TypeUse
aggressiveRaised or hostile exchange β€” usually the highest-value segments
negativeAdverse tone short of aggression
positiveCooperative exchange
uninterestingNo raised voices. Still frequently substantive β€” rank it down, don't drop it

Responses to handle

StatusMeansWhat to do
401{"error":{"status":"unauthenticated"}} β€” token missing, wrong, expired, or the service was deleted Not retryable. Create a new service in the console and switch to its token
403"identity service cannot access endpoint …"The endpoint is user-only. Use one from the table above
403"caller does not have permission to perform action …" Not retryable. Give the service a role with View devices and View events
404{"message": "not found"} β€” wrong org id, or the wrong regional host for it Check the host first, then the id
429 Too many requests. The configured limit is 150 per minute per endpoint for each service Wait Retry-After seconds and retry; double the wait on repeats
5xxTransient server faultRetry a few times with the same backoff, then fail loudly
200…with a filter you thought you applied Unrecognized filters are dropped silently β€” check the timestamps you got back

Behavior described here is current as of 2 October 2026. The set of endpoints open to service tokens, the gte/lte behavior and the page cap may change β€” check back before relying on any of them staying as described.