Skip to content

Events and webhooks

You can follow what happens on your cameras in two ways: by polling (GET /events, events scope) or by receiving it in your own system through a webhook (webhooks scope). Both see the same thing: the events of the cameras the key can see.

Catalog

Every event has a type. When you create a webhook you choose the ones you want by exact name or with wildcards: * (everything), camera.* (all camera events), vision.*.

Type When
camera.online, camera.offline The camera connected or disconnected
camera.linked A newly provisioned camera connected for the first time
camera.revoked It was decommissioned
camera.vision_threshold A rule on a point of interest crossed its threshold
camera.lease.taken, camera.lease.released A key took or released control of the camera (details.holder, details.by)
camera.stream_started, camera.crowd.*… Events from the video server and its applications
camera.<src>.<event> What the camera writes to its own log, in real time: camera.motion.motion_start, camera.patrol.started, camera.ptz.moving, camera.voice.wake, camera.record.started, camera.upload.snapshot_sent, camera.system.boot…
vision.result Every result from points of interest

Events caused by an API request carry details.actor (api:<key name>).

Payload

Each delivery is a POST with a JSON body:

{
  "type": "camera.lease.taken",
  "created_at": "2026-11-08T14:03:11-03:00",
  "space": {"id": "…", "slug": "deposito"},
  "data": {
    "camera_id": "8f0c…",
    "camera_name": "Depósito",
    "kind": "lease.taken",
    "record_id": 1234,
    "message": "La API «Robot del depósito» tomó el control.",
    "details": {"actor": "api:Robot del depósito", "holder": "Robot del depósito", "expires_at": "…"}
  }
}

message is the human-readable text shown in the panel, in Spanish. With these headers:

Header Meaning
X-Tero-Event The type
X-Tero-Delivery The delivery id. It is repeated on retries: use it to drop duplicates
X-Tero-Timestamp Seconds since 1970, when it was signed
X-Tero-Signature sha256=<HMAC-SHA256(secret, "<timestamp>.<body>")> in hex

Verifying the signature

  1. Take the raw body, exactly as received: re-serializing the JSON changes the signature.
  2. Reject an X-Tero-Timestamp more than 5 minutes away from your clock.
  3. Compute the HMAC and compare it in constant time.
  4. Drop any X-Tero-Delivery you have already processed: delivery is "at least once".

The SDKs do this for you (verifyWebhook in TypeScript, verify_webhook in Python; see Guides). Without the SDK:

TypeScript / JavaScript — sdk/js/examples/portal/webhook-verify.ts

const ts = headers['x-tero-timestamp'] ?? '';
if (!/^\d+$/.test(ts) || Math.abs(now - Number(ts)) > 300) return false;
const expected = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const got = headers['x-tero-signature'] ?? '';
return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected));

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

ts = headers.get("X-Tero-Timestamp", "")
if not ts.isdigit() or abs((now or time.time()) - int(ts)) > 300:
    return False
expected = "sha256=" + hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers.get("X-Tero-Signature", ""))

Deliveries and retries

  • If your system doesn't answer 2xx within 5 seconds, the delivery is retried after 30 s, 2 min, 10 min, 30 min and 2 h; after that it is marked failed. GET /webhooks/{id}/deliveries shows each attempt.
  • After 50 consecutive failures the webhook disables itself and says so in disabled_reason; turn it back on with PATCH {"enabled": true}.
  • POST /webhooks/{id}/ping sends a signed ping so you can test your endpoint.
  • The URL must be https and public: the connection is pinned to the address that was checked, and redirects are checked again. The URL is stored encrypted (it may contain a token of yours) and afterwards only the host is shown. Up to 5 webhooks per space, 64 KB per body.

Reading without a webhook

GET /events?since=…&kind=… and GET /cameras/{id}/events return the same events, paginated. Useful to catch up after an outage.