Eventos y webhooks
Lo que pasa en las cámaras se lee de dos formas: preguntando (GET /events, alcance events) o recibiéndolo en tu sistema con un webhook (alcance webhooks). Los dos ven lo mismo: los eventos de las cámaras que la clave puede ver.
Catálogo
Cada evento tiene un type. Al crear un webhook se eligen los que querés con su nombre exacto o con comodines: * (todo), camera.* (todo lo de las cámaras), vision.*.
| Tipo | Cuándo |
|---|---|
camera.online, camera.offline |
La cámara se conectó o se desconectó |
camera.linked |
Una cámara dada de alta se conectó por primera vez |
camera.revoked |
Se dio de baja |
camera.vision_threshold |
Una regla de un punto de interés pasó su límite |
camera.lease.taken, camera.lease.released |
Una clave tomó o soltó el control de la cámara (details.holder, details.by) |
camera.stream_started, camera.crowd.*… |
Los del servidor de video y sus aplicaciones |
camera.<src>.<evento> |
Lo que escribe la cámara en su registro, en tiempo real: 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 |
Cada resultado de los puntos de interés |
Los eventos que nacen de un pedido de la API llevan details.actor (api:<nombre de la clave>).
Formato
Cada envío es un POST con JSON:
{
"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": "…"}
}
}
Con estas cabeceras:
| Cabecera | Qué es |
|---|---|
X-Tero-Event |
El type |
X-Tero-Delivery |
El id del envío. Se repite en los reintentos: usalo para descartar duplicados |
X-Tero-Timestamp |
Segundos desde 1970, cuando se firmó |
X-Tero-Signature |
sha256=<HMAC-SHA256(secret, "<timestamp>.<cuerpo>")> en hexadecimal |
Verificar la firma
- Tomá el cuerpo crudo, tal cual llegó: volver a serializar el JSON cambia la firma.
- Rechazá un
X-Tero-Timestampde más de 5 minutos de diferencia con tu reloj. - Calculá el HMAC y comparalo en tiempo constante.
- Descartá un
X-Tero-Deliveryque ya procesaste: la entrega es "al menos una vez".
Los SDK lo traen hecho (verifyWebhook en TypeScript, verify_webhook en Python; ver Guías). Sin el 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", ""))
Entregas y reintentos
- Si tu sistema no contesta 2xx en 5 segundos, se reintenta a los 30 s, 2 min, 10 min, 30 min y 2 h; después el envío queda
failed.GET /webhooks/{id}/deliveriesmuestra cada uno. - Con 50 fallas seguidas el webhook se apaga solo y lo dice en
disabled_reason; se prende de nuevo conPATCH{"enabled": true}. POST /webhooks/{id}/pingmanda un ping firmado para probar.- La URL tiene que ser
httpsy pública: la conexión se fija a la dirección revisada y las redirecciones se vuelven a revisar. La URL se guarda cifrada (puede llevar un token tuyo) y después solo se ve el servidor. Hasta 5 webhooks por espacio, 64 KB por cuerpo.
Leer sin webhook
GET /events?since=…&kind=… y GET /cameras/{id}/events devuelven lo mismo, paginado. Sirve para ponerse al día después de un corte.