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
- Take the raw body, exactly as received: re-serializing the JSON changes the signature.
- Reject an
X-Tero-Timestampmore than 5 minutes away from your clock. - Compute the HMAC and compare it in constant time.
- Drop any
X-Tero-Deliveryyou 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}/deliveriesshows each attempt. - After 50 consecutive failures the webhook disables itself and says so in
disabled_reason; turn it back on withPATCH{"enabled": true}. POST /webhooks/{id}/pingsends a signed ping so you can test your endpoint.- The URL must be
httpsand 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.