Skip to content

SDK guides

End-to-end walkthroughs with the TypeScript and Python SDKs. Throughout, tv is the client (new TeroVision({ apiKey }) or TeroVision(api_key)) and camera ids are the id, not the device_uid. The samples live in sdk/js/examples/portal and sdk/python/examples/portal, and each SDK's test suite runs them.

Commands. Anything you ask the camera to do (go to a position, record, take a snapshot) answers 202 with a command and runs afterwards. waitForCommand / wait_for_command polls it until done; status tells you what actually happened (done, failed, timeout, offline, rejected…) and actor who asked for it.

Cameras

List all of them (the SDK walks the pages for you), check their status and provision a new one. The provisioning QR carries the camera's credentials and is delivered only once: the Wi-Fi network is added on your machine with withWifi and never travels to TeroVision. Provisioning expires after 30 minutes if the camera doesn't connect.

TypeScript / JavaScript — sdk/js/examples/portal/cameras.ts

// Every camera this key can see, across all pages.
for await (const camera of tv.cameras.all()) {
  const status = await tv.cameras.status(camera.id);
  console.log(camera.name, status.recording?.running ? 'recording' : 'idle');
}

// Provision a new camera. The QR comes back only once: treat it as a secret.
const { camera, provisioning } = await tv.cameras.create({ name: 'Depósito' });
const qr = withWifi(provisioning.qr_text, wifi.ssid, wifi.password); // Wi-Fi is added here, never sent to TeroVision
showQrToTheCamera(qr);
const online = await tv.cameras.waitOnline(camera.id, { timeoutMs: 15 * 60_000 });

Python — sdk/python/examples/portal/cameras.py

# Every camera this key can see, across all pages.
for camera in tv.cameras.all():
    status = tv.cameras.status(camera["id"])
    print(camera["name"], "recording" if status["recording"]["running"] else "idle")

# Provision a new camera. The QR comes back only once: treat it as a secret.
created = tv.cameras.create("Depósito")
qr = with_wifi(created["provisioning"]["qr_text"], ssid, password)  # Wi-Fi is added here, never sent to TeroVision
show_qr_to_the_camera(qr)
online = tv.cameras.wait_online(created["camera"]["id"], timeout=15 * 60)

Decommissioning is DELETE /cameras/{id}: it deletes the credentials and retained data, exactly like the panel.

PTZ

move is a 500 ms step that the camera stops on its own: to keep moving, repeat it every 300 ms, just like the panel's pad. Going to a position, home or a saved preset are commands. A move through the API counts as a manual move: it pauses person tracking for one minute and pauses the camera's patrol (for whatever pause it is configured with).

TypeScript / JavaScript — sdk/js/examples/portal/ptz.ts

// `move` is a 500 ms step that the camera stops by itself: repeat it every 300 ms to keep moving.
for (let i = 0; i < 5; i++) {
  await tv.ptz.move(cameraId, 'left');
  await sleep(300);
}
await tv.ptz.stop(cameraId);

// Absolute moves and presets are commands: they answer 202 and run on the camera.
const goto = await tv.ptz.goto(cameraId, 1200, 300);
const result = await tv.cameras.waitForCommand(cameraId, goto.id);
console.log(result.status, result.actor); // "done", "api:<your key name>"

const { presets } = await tv.ptz.presets(cameraId);
const door = presets.find((p) => p.name === 'Puerta');
if (door) await tv.ptz.gotoPreset(cameraId, door.id);

Python — sdk/python/examples/portal/ptz.py

# `move` is a 500 ms step that the camera stops by itself: repeat it every 300 ms to keep moving.
for _ in range(5):
    tv.ptz.move(camera_id, "left")
    sleep(0.3)
tv.ptz.stop(camera_id)

# Absolute moves and presets are commands: they answer 202 and run on the camera.
goto = tv.ptz.goto(camera_id, 1200, 300)
result = tv.cameras.wait_for_command(camera_id, goto["id"])
print(result["status"], result["actor"])  # "done", "api:<your key name>"

door = next((p for p in tv.ptz.presets(camera_id)["presets"] if p["name"] == "Puerta"), None)
if door:
    tv.ptz.goto_preset(camera_id, door["id"])

curl — resources/devportal/samples/curl/ptz.sh

curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" -H 'Content-Type: application/json' \
  "$API/cameras/$CAMERA_ID/ptz/move" -d '{"direction": "left"}'
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/cameras/$CAMERA_ID/ptz/stop"
# 202 with a command: poll GET /cameras/{id}/commands/{command} until "done": true
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" -H 'Content-Type: application/json' \
  "$API/cameras/$CAMERA_ID/ptz/goto" -d '{"x": 1200, "y": 300}'

Saving and deleting presets requires the admin role. While the camera is uploading a snapshot it does not respond to PTZ.

Recording

Configuring continuous recording (and its upload to the bucket) requires admin; starting and stopping it only requires operate. Recordings show up in /recordings once the panel finds them in the bucket (every 10 minutes), with a signed URL that expires.

TypeScript / JavaScript — sdk/js/examples/portal/recording.ts

// Continuous recording to the SD card, uploading each finished segment to the space's bucket (admin role).
await tv.recording.update(cameraId, { enabled: true, upload: true, segment_minutes: 30 });

// Start and stop on demand (operate role).
const start = await tv.recording.start(cameraId, { segment_minutes: 30 });
await tv.cameras.waitForCommand(cameraId, start.id);

// What reached the bucket (indexed every 10 minutes), with short-lived signed URLs.
const { data } = await tv.recordings.list({ camera_id: cameraId, kind: 'recording', from: new Date(Date.now() - 86_400_000) });
const first = data[0];
if (first) {
  const file = await tv.recordings.get(first.id, { download: true });
  console.log(file.url, `valid for ${file.url_expires_in} s`);
}

Python — sdk/python/examples/portal/recording.py

# Continuous recording to the SD card, uploading each finished segment to the space's bucket (admin role).
tv.recording.update(camera_id, enabled=True, upload=True, segment_minutes=30)

# Start and stop on demand (operate role).
start = tv.recording.start(camera_id, segment_minutes=30)
tv.cameras.wait_for_command(camera_id, start["id"])

# What reached the bucket (indexed every 10 minutes), with short-lived signed URLs.
since = datetime.now(timezone.utc) - timedelta(days=1)
files = tv.recordings.list(camera_id=camera_id, kind="recording", since=since)["data"]
if files:
    item = tv.recordings.get(files[0]["id"], download=True)
    print(item["url"], f"valid for {item['url_expires_in']} s")

Live video

POST /cameras/{id}/live is for your server: it returns a LiveKit URL with a single-viewer, subscribe-only token, plus a signed HLS URL. Both expire and are not renewed automatically: request a new session a minute before expiry, which checks permissions again. Pass only webrtc or hls to the browser — never the key.

TypeScript / JavaScript — sdk/js/examples/portal/live.ts

// On your server: the session is view-only and expires; ask for a new one a minute before `expires_in`.
const session = await tv.live.create(cameraId, { protocols: ['webrtc', 'hls'] });
const forTheBrowser = { webrtc: session.webrtc, hls: session.hls }; // never the API key

Python — sdk/python/examples/portal/live.py

# On your server: the session is view-only and expires; ask for a new one a minute before `expires_in`.
session = tv.live.create(camera_id, protocols=["webrtc", "hls"])
for_the_browser = {"webrtc": session["webrtc"], "hls": session["hls"]}  # never the API key

In the browser, with livekit-client installed, the @terovision/sdk/live helper puts the video into a <video> element:

TypeScript / JavaScript — sdk/js/examples/portal/live-browser.ts

// In the browser (needs livekit-client). `fetchSession` calls YOUR server, which calls tv.live.create.
const session = await fetchSession();
const view = await joinLive(session.webrtc!, { element, muted: true });
// Renew before it expires: same camera, new token, permission checked again.
setTimeout(async () => {
  await view.leave();
  await liveInTheBrowser(fetchSession, element);
}, Math.max(5_000, view.expiresAt.getTime() - Date.now() - 60_000));

Talk

Requires operate permission and a camera with features.talk. The channel is half duplex (while it is open the camera mutes its microphone, including in recordings and in "Hola Tero"), lasts up to 120 s, and there is one per camera (if someone is already talking, 409). You send binary frames of 16 kHz mono PCM s16le, 20 ms each (640 bytes).

TypeScript / JavaScript — sdk/js/examples/portal/talk.ts

// Half duplex: while the channel is open the camera mutes its microphone. Up to 120 s per channel.
const channel = await tv.talk.open(cameraId);
const ws = new Ws(channel.ws_url); // open it within connect_within_s (60 s)
ws.binaryType = 'arraybuffer';
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
for await (const frame of pcm) {
  ws.send(frame); // binary frames of channel.audio.frame_ms (20 ms) at channel.audio.sample_rate
}
ws.close();
await tv.talk.close(cameraId);

Python — sdk/python/examples/portal/talk.py

# Half duplex: while the channel is open the camera mutes its microphone. Up to 120 s per channel.
channel = tv.talk.open(camera_id)
with connect(channel["ws_url"]) as ws:  # e.g. websockets.sync.client.connect; open it within connect_within_s
    for frame in frames:  # 640-byte binary frames: 20 ms of PCM s16le mono at 16 kHz
        ws.send(frame)
tv.talk.close(camera_id)

Bucket

The space's S3 (or S3-compatible: MinIO, R2, Wasabi) bucket. Cameras provisioned from now on upload there; existing cameras are not updated (decommission and provision them again). secret_key is write-only, and the endpoint must be https and public: a hostname that resolves to an internal network is rejected.

TypeScript / JavaScript — sdk/js/examples/portal/storage.ts

// The space's own S3-compatible bucket (admin role). Cameras provisioned from now on upload there.
const { storage, test } = await tv.storage.set({
  endpoint: 'https://s3.sa-east-1.amazonaws.com',
  region: 'sa-east-1',
  bucket: 'camaras-deposito',
  access_key: secrets.accessKey,
  secret_key: secrets.secretKey, // write-only: never returned again
  test: true,
});
console.log(storage.bucket, storage.access_key_hint, test?.ok);

// Later: list, upload and delete a tiny object to check it still works.
const check = await tv.storage.test({ write: true });

Python — sdk/python/examples/portal/storage.py

# The space's own S3-compatible bucket (admin role). Cameras provisioned from now on upload there.
saved = tv.storage.set(
    endpoint="https://s3.sa-east-1.amazonaws.com",
    region="sa-east-1",
    bucket="camaras-deposito",
    access_key=access_key,
    secret_key=secret_key,  # write-only: never returned again
    test=True,
)
print(saved["storage"]["bucket"], saved["storage"]["access_key_hint"], saved["test"]["ok"])

check = tv.storage.test(write=True)  # list, upload and delete a tiny object

Webhooks

TeroVision notifies your system of every camera event. The signing secret (whsec_…) is shown only when the webhook is created. The event catalog and the payload format are in Events and webhooks.

TypeScript / JavaScript — sdk/js/examples/portal/webhooks.ts

// The signing secret (whsec_…) is shown only here: store it like a password.
const hook = await tv.webhooks.create({ url: 'https://example.com/tero', events: ['camera.*', 'vision.result'] });
saveSecret(hook.id, hook.secret);

Python — sdk/python/examples/portal/webhooks.py

# The signing secret (whsec_...) is shown only here: store it like a password.
hook = tv.webhooks.create("https://example.com/tero", ["camera.*", "vision.result"])
save_secret(hook["id"], hook["secret"])

On the receiving side, verify the signature against the raw body, drop duplicate deliveries and answer 2xx within 5 seconds:

TypeScript / JavaScript — sdk/js/examples/portal/webhooks.ts

const seen = new Set<string>(); // use your database in production: delivery is "at least once"

return createServer(async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  const rawBody = Buffer.concat(chunks).toString('utf8'); // the raw body: re-serialized JSON breaks the signature

  const ok = await verifyWebhook({ headers: req.headers as Record<string, string>, rawBody, secret });
  if (!ok) {
    res.writeHead(401).end();
    return;
  }
  const delivery = String(req.headers['x-tero-delivery'] ?? '');
  if (!seen.has(delivery)) {
    seen.add(delivery);
    onEvent(JSON.parse(rawBody));
  }
  res.writeHead(204).end(); // answer 2xx within 5 s; do slow work afterwards
});

Python — sdk/python/examples/portal/webhooks.py

# `raw_body` is the body exactly as received (request.get_data() in Flask, await request.body() in FastAPI).
if not verify_webhook(headers, raw_body, secret):
    return 401
delivery = headers.get("X-Tero-Delivery") or headers.get("x-tero-delivery")
if delivery not in seen:  # use your database in production: delivery is "at least once"
    seen.add(delivery)
    on_event(json.loads(raw_body))
return 204  # answer 2xx within 5 s; do slow work afterwards

Control lease

With the control scope, a key can take a camera for up to 5 minutes (renewable). While it holds it, automations don't move or change the camera, tracking doesn't move it and no other key can either. People in the panel still can, and anyone with configure can release it. The full story is in API and automations.

The convenient way: hold takes control, renews it automatically at a third of the TTL and releases it when you are done — even if something fails.

TypeScript / JavaScript — sdk/js/examples/portal/leases.ts

// Take control for a while: automations will not move or change this camera until you release it.
try {
  return await tv.leases.hold(cameraId, async ({ lease, signal }) => {
    console.log(`in control as «${lease.holder}» until ${lease.expires_at}`);
    for (const direction of ['left', 'left', 'up'] as const) {
      if (signal.aborted) break; // a renewal failed: stop moving
      await tv.ptz.move(cameraId, direction);
    }
    return tv.ptz.goto(cameraId, 1200, 300);
  }, { ttlSeconds: 60 }); // renewed every 20 s, released at the end (also on errors)
} catch (err) {
  if (err instanceof ConflictError && err.code === 'lease_held') {
    console.log('another integration is driving this camera'); // err.body.error.lease says who and until when
    return null;
  }
  throw err;
}

Python — sdk/python/examples/portal/leases.py

# Take control for a while: automations will not move or change this camera until you release it.
try:
    with tv.leases.hold(camera_id, ttl_seconds=60) as lease:  # renewed every 20 s, released on exit (also on errors)
        print(f"in control as «{lease['holder']}» until {lease['expires_at']}")
        for direction in ("left", "left", "up"):
            if lease["lost"].is_set():  # a renewal failed: stop moving
                break
            tv.ptz.move(camera_id, direction)
        return tv.ptz.goto(camera_id, 1200, 300)
except ConflictError as err:
    if err.code != "lease_held":
        raise
    print("another integration is driving this camera")  # err.body["error"]["lease"] says who and until when
    return None

By hand:

TypeScript / JavaScript — sdk/js/examples/portal/leases.ts

const lease = await tv.leases.take(cameraId, { ttlSeconds: 300 }); // 10–300 s
await tv.leases.take(cameraId, { ttlSeconds: 300 }); // same key: renews
const { lease: current, held_by_this_key } = await tv.leases.get(cameraId);
await tv.leases.release(cameraId);

Python — sdk/python/examples/portal/leases.py

lease = tv.leases.take(camera_id, ttl_seconds=300)  # 10-300 s
tv.leases.take(camera_id, ttl_seconds=300)  # same key: renews
state = tv.leases.get(camera_id)  # {"lease": {...}, "held_by_this_key": True}
tv.leases.release(camera_id)

curl — resources/devportal/samples/curl/lease.sh

# Take (201) or renew (200) control for up to 300 s. Another key gets 409 lease_held.
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" -H 'Content-Type: application/json' \
  "$API/cameras/$CAMERA_ID/lease" -d '{"ttl_seconds": 120}'
curl -s -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/cameras/$CAMERA_ID/lease"
curl -s -X DELETE -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/cameras/$CAMERA_ID/lease"

Automations

With automations:read you can list them (without the graph: flows are edited in the panel), with automations:write you can turn them on and off (same as the panel's switch; a managed automation turns its feature on or off in the camera), and with automations:run you can run the ones that have a Manual trigger. A run started through the API is real, not a test, and goes through the same caps, the same kill switch and the same permissions as any other run.

TypeScript / JavaScript — sdk/js/examples/portal/automations.ts

// Read (automations:read): the graph is never returned; edit flows in the panel.
for await (const flow of tv.automations.all()) {
  console.log(flow.name, flow.active ? 'on' : 'off', flow.runnable ? '(has a Manual trigger)' : '');
}

// Run one that has a Manual trigger (automations:run). It is a real run, not a test.
const flow = (await tv.automations.list()).data.find((a) => a.runnable && a.active);
if (flow) {
  try {
    const run = await tv.automations.run(flow.id, { camera_id: cameraId, data: { order: 'A-12' } });
    const done = await tv.automations.waitForRun(flow.id, run.id);
    for (const step of done.steps) console.log(step.node, step.status, step.code ?? '');
  } catch (err) {
    if (err instanceof RateLimitError && err.code === 'automation_capped') console.log('engine cap reached, try later');
    else throw err;
  }
  // Turn it off and on (automations:write), through the same switch as the panel.
  await tv.automations.disable(flow.id);
  await tv.automations.enable(flow.id);
}

Python — sdk/python/examples/portal/automations.py

# Read (automations:read): the graph is never returned; edit flows in the panel.
for flow in tv.automations.all():
    print(flow["name"], "on" if flow["active"] else "off", "(has a Manual trigger)" if flow["runnable"] else "")

# Run one that has a Manual trigger (automations:run). It is a real run, not a test.
flow = next((a for a in tv.automations.list()["data"] if a["runnable"] and a["active"]), None)
if flow:
    try:
        run = tv.automations.run(flow["id"], camera_id=camera_id, data={"order": "A-12"})
        done = tv.automations.wait_for_run(flow["id"], run["id"])
        for step in done["steps"]:
            print(step["node"], step["status"], step["code"] or "")
    except RateLimitError as err:
        if err.code != "automation_capped":
            raise
        print("engine cap reached, try later")
    # Turn it off and on (automations:write), through the same switch as the panel.
    tv.automations.disable(flow["id"])
    tv.automations.enable(flow["id"])

curl — resources/devportal/samples/curl/automations.sh

curl -s -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/automations"
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/automations/$AUTOMATION_ID/disable"
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/automations/$AUTOMATION_ID/enable"
# Only automations with a Manual trigger. 202 with the run; follow it with GET .../runs/{run}.
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" -H 'Content-Type: application/json' \
  "$API/automations/$AUTOMATION_ID/run" -d '{"camera_id": "'"${CAMERA_ID:-}"'", "data": {"order": "A-12"}}'