Out of the twin
Read this before you design around it
Outbound is thinner than inbound. You can push almost anything in; what comes back out today is telemetry and alarms over webhooks or a live socket. Ticket and work-order events do not reach an external subscriber yet — they travel on the platform's internal bus, and the external route for tickets is polling. The tables below say exactly what fires and what does not, so you can plan around the real surface rather than an intended one.
Three ways data leaves the platform:
| Channel | Shape | Good for |
|---|---|---|
| Webhooks | HTTP POST to your URL, signed | Server-to-server, survives your service restarting |
| WebSocket | socket.io stream, per building | Live dashboards, operator screens |
| Polling | Ordinary GET requests | Everything the first two do not cover yet — tickets above all |
Webhooks
Subscribe
POST /api/v1/webhooks{ "url": "https://fm.example.com/hooks/tango", "events": ["building.alarm.triggered"] }The response contains a secret — in full, once. Every later read masks it to the first 8 characters. Store it when you create the subscription.
Requires platform.webhooks:write, which only the manager role carries: registering a URL means "send our event stream there", so it is deliberately not a low-privilege action.
Your URL must be publicly reachable, over https
Registration resolves the host and rejects loopback, private ranges (10/8, 172.16/12, 192.168/16), link-local and cloud-metadata addresses, CGNAT and multicast. The check runs again immediately before each delivery, so DNS that later points inward stops working too. Plain http is refused outside local development.
Practically: http://localhost:3000/hook cannot be registered. Use a tunnel with a public https URL while you develop.
Redirects are not followed — a 3xx is recorded as a failed delivery, not chased.
What a delivery looks like
POST /hooks/tango HTTP/1.1
Content-Type: application/json
X-TV-Signature: 9f0c… ← HMAC-SHA256 of the raw body, hex, keyed by your secret
X-TV-Idempotency-Key: 7c1e…:building.alarm.triggered:1754300000000
X-TV-Delivery-Attempt: 1{ "event": "building.alarm.triggered", "data": { "buildingId": "…", "…": "event-specific" }, "timestamp": "2026-08-04T09:12:33.120Z" }Verify the signature over the raw request body before parsing, with a constant-time comparison. Deduplicate on X-TV-Idempotency-Key: a retry repeats it, so at-least-once delivery becomes exactly-once processing on your side.
Retries, and when we give up
- Timeout per attempt: 10 seconds. Anything other than a 2xx counts as a failure.
- Up to 6 attempts total, backing off 1 min → 5 min → 15 min → 1 h → 3 h with ±10% jitter — about 4.5 hours from first attempt to the end. After that the delivery is dead-lettered and never retried.
- 10 consecutive failures pause the subscription: new events stop being queued for it. After an hour of quiet one event is let through as a probe; a success clears the counter. To resume immediately:
POST /api/v1/webhooks/{webhookId}/reset-failures. GET /api/v1/webhooks/{webhookId}/deliveriesreturns the last 50 attempts with status, HTTP code and a truncated response body — the first place to look when "we stopped getting events".
Which events actually fire
Four event types can be subscribed to. Only two of them are produced by the platform today:
| Event | Subscribable | Fires today | Raised by |
|---|---|---|---|
building.point.updated | yes | yes | Every telemetry ingest |
building.alarm.triggered | yes | yes | Fault detection |
building.element.status_changed | yes | no | Nothing publishes it yet |
building.changeset.applied | yes | no | Nothing publishes it yet |
Subscribing to the bottom two is accepted and then silent. We list them because you will see them in the SDK's event catalogue and in the n8n trigger node, and a silent subscription is otherwise indistinguishable from a broken one.
One more asymmetry worth knowing: building.fault.created is published by fault detection but is not in the webhook dispatcher's list, so it cannot be delivered to you. Alarms (building.alarm.triggered) are the outward-facing signal.
Live socket
For screens rather than servers, tv-api exposes a socket.io namespace:
import { io } from 'socket.io-client';
const socket = io('https://tv-api.k8s.tangovision.dev/buildings', {
auth: { token: accessToken }, // or an Authorization: Bearer header
});
socket.emit('join', { buildingId }); // rooms are per building
socket.on('building.point.updated', console.log);Carries the same four building.* types (with the same two actually firing) plus the WiFi-sensing occupancy stream — wifi-sensing.event, wifi-sensing.occupancy.changed, wifi-sensing.hvac.presence, wifi-sensing.meeting.lifecycle — which webhooks do not deliver.
The namespace validates the Keycloak token against the realm's JWKS, and its CORS allow-list comes from the deployment's CORS_ORIGINS. It fails closed: if your origin is not on that list the connection is refused, and no message is sent that would tell you why. If a browser client cannot connect at all, that is the first thing to check with us.
Tickets and work orders: what exists, and what does not
Inside the platform, the service desk publishes real events on the internal bus (subject tv.building.{buildingId}.{type}):
| Event | When |
|---|---|
service-desk.ticket.created | A ticket is created |
service-desk.ticket.updated | State, assignment or fields change |
service-desk.ticket.sla_warning | An SLA clock crosses its warning threshold |
service-desk.ticket.sla_breached | An SLA clock is breached |
CAFM publishes cafm.work-order.created / .updated / .completed the same way, and the service desk consumes them to link a work order to its ticket.
None of these are deliverable to an external subscriber today. They are not in the webhook dispatcher's event list, and the bus itself is in-cluster. A module running inside the platform can subscribe to them (that is level 3 and the Events guide); your own system, outside, cannot.
So for "tell me when a ticket changes", the honest answer today is poll:
GET /api/service-desk/requests?buildingId={uuid}&state=in_progress&limit=200served from the Building OS origin (https://building-os.k8s.tangovision.dev/api/service-desk/requests). Filters: state, priority, category, assignedUserId, assignedTeamId, spaceId, storeyId, elementId, search, overdue, with page and limit (max 200).
There is no updatedSince filter yet
The list endpoint has no "changed since" parameter, so incremental sync means pulling the open states on a schedule and comparing updatedAt yourself. For a few hundred open tickets per building this is entirely workable; it is not a design you would choose if the filter existed. If you need it, say so — it is a small addition and knowing someone is waiting on it is what schedules it.
Choosing
- Telemetry or alarms, server-to-server → webhooks.
- A live screen → the socket.
- Tickets, work orders, anything else → poll, and read Both ways for how to keep two ticket systems in step without them fighting.