Into the twin
Two shapes of inbound data, with two different contracts:
| You have | Use | Keyed by |
|---|---|---|
| One current value per room — rent status, open-ticket count, CO₂, cleaning state | Data layers | The room's identifier |
| A stream of readings over time — pressure, temperature, energy | Telemetry observations | A point on a piece of equipment |
Tickets are a third case with its own page — see Both ways.
Everything below is plain HTTP against tv-api. Base URL in the hosted environment: https://tv-api.k8s.tangovision.dev. Every request carries Authorization: Bearer <token> — see Getting access.
Data layers
A data layer is one value per graph node (today: per room), rendered on the 2D floor plan and on the BIM model from the same source.
1. Create the layer
POST /api/v1/buildings/{buildingId}/data-layers{
"key": "fm-open-tickets",
"name": "Открытые заявки",
"description": "Open tickets per room, from the FM system",
"valueType": "NUMBER"
}| Field | Rules |
|---|---|
key | Required. URL-safe slug: ^[a-z0-9][a-z0-9-]{1,62}$. Unique per building — a repeat returns 409. |
name | Required. What the user sees in the layer list. |
description | Optional. |
valueType | Required, one of NUMBER, STRING, BOOLEAN, ENUM. Immutable — PATCH deliberately refuses to change it, because every stored value would become invalid. Need a different type? Create another layer. |
valueSchema | Optional JSON Schema, compiled on create and checked against every value on upload. An invalid schema fails the create with 400. |
legend | Optional colour legend — see the callout below. |
metadata | Optional free-form object (we use {"synthetic": true} to mark demo layers). |
2. Upload values
PUT /api/v1/buildings/{buildingId}/data-layers/{layerId}/values{
"keyBy": "externalId",
"replace": false,
"values": {
"3lPQxG$0v9zRZ1mB7kYtE2": 4,
"1aBcDe$FgHiJkLmNoPqRs": 0
}
}| Field | Meaning |
|---|---|
keyBy | externalId (default) — the keys are IFC GlobalIds. id — the keys are internal room UUIDs. See Identity. |
replace | false (default) upserts only the rooms in this payload. true first deletes every value in the layer, so the payload becomes the complete state. |
values | An object of identifier → value. Not an array — free-form keys have to sit under one property because the API rejects unknown top-level fields. |
Value types are checked per entry before anything is written: NUMBER wants a finite JSON number, BOOLEAN a JSON boolean, STRING and ENUM a JSON string. A mismatch fails the whole request with 400 and names the offending key — uploads are all-or-nothing, never half-applied.
The response tells you what actually landed:
{ "written": 812, "matched": 812, "unmatched": ["3lPQ...unknown"], "deleted": 0 }unmatched lists identifiers that resolved to no room in this building. Read it. A 200 with written: 0 is a successful request that changed nothing, and it is the single most common way an integration "works" while the plan stays blank.
If nothing matched, the response carries a diagnostics object that says why, so you look in the right place:
reason | What it means |
|---|---|
BUILDING_HAS_NO_SPACES | The building graph is empty — no model has been imported and no rooms were created. |
BUILDING_HAS_NO_EXTERNAL_IDS | The rooms exist but carry no GlobalId, so keyBy: "externalId" can never match. Use keyBy: "id". |
IDENTIFIERS_NOT_IN_BUILDING | Both sides are populated; these particular identifiers are from another building or another model. |
Limits for bulk loads
- 10 000 identifiers per request. More returns
400— chunk your payload. - 10 MB request body.
- tv-api rate-limits callers (on the order of 100 requests/minute) and answers
429withRetry-After. Batch: one request with 10 000 values, not 10 000 requests with one. - Reading back is paginated with a server-side cap of 100 per page (
limit,offset). A largerlimitis rejected, not silently clamped — so a full read of a large layer is a loop, not one call.
3. Read values back
GET /api/v1/buildings/{buildingId}/data-layers/{layerId}/values?gte=3&limit=100{
"data": [
{ "entityId": "0d2f…", "externalId": "3lPQxG$0v9zRZ1mB7kYtE2", "value": 4, "updatedAt": "2026-08-04T09:12:33.120Z" }
],
"total": 41, "limit": 100, "offset": 0
}Filters: eq (exact, coerced to the layer's type), gt / gte / lt / lte (NUMBER layers only — anything else returns 400), and in. Note that in currently accepts one value; a comma-separated list returns 400 telling you to issue one eq per value. Plan for one request per value until that lands.
An ENUM legend label is the value
If a layer has no explicit legend, colours and labels are derived from the data: for ENUM and STRING layers each distinct value becomes a legend entry whose label is the value itself, sorted alphabetically. Upload OCCUPIED / VACANT and a Russian-speaking operator reads OCCUPIED / VACANT on screen. We hit this live and had to fix it in front of a customer.
Two ways out, both fine:
Write values in the language you want on screen —
"Занято","Свободно". Simple, and correct as long as one language is enough.Send an explicit
legendand keep machine-readable codes as values. An explicit legend that matches at least one value wins outright, labels included:json"legend": [ { "value": "OCCUPIED", "label": "Занято", "color": "#ef4444" }, { "value": "VACANT", "label": "Свободно", "color": "#22c55e" } ]Entries take either
value(exact match) ormin/max(inclusive range, numeric layers). A legend that matches nothing is treated as unusable and the derived colouring takes over — so a typo invaluedegrades quietly rather than blanking the layer.
Two more derived-legend details worth knowing: BOOLEAN layers with no legend render as «да»/«нет», and beyond ten distinct categories the remainder collapses into a single «прочее (N)» bucket. Those labels are literal Russian strings in the shared rendering package, independent of the viewer's UI language. If your audience is English-speaking, send an explicit legend.
Filling a layer without any code
n8n covers the same contract with no programming: schedule, HTTP call, transform, retry. Our node package @tv/n8n-nodes-building-os adds Building OS resources (sites, buildings, storeys, spaces, elements, points, telemetry) and an event trigger. Data layers do not have a dedicated node yet — use n8n's generic HTTP Request node with the same Building OS API credential selected as a predefined credential type, and point it at the endpoints above. (If your n8n version does not offer our credential there, a Header Auth credential carrying Authorization: Bearer … works too — you then refresh the token yourself.)
The credential is a Keycloak client-credentials service account: base URL, Keycloak URL, realm, client id, client secret. n8n refreshes the token itself when tv-api answers 401.
Telemetry: readings over time
Monitoring data — a pressure, a temperature, a meter — is not a data layer. It goes to a point, and points hang off equipment in the graph:
building → storey → space → element (the AHU) → point (supply temperature)So there are two steps: make sure the point exists, then stream values at it.
Create the point once
POST /api/v1/buildings/{buildingId}/points{
"elementId": "…uuid of the AHU…",
"pointType": "SENSOR",
"name": "Supply air temperature",
"quantityKind": "Temperature",
"unit": "Cel"
}elementId is required — a point always belongs to a piece of equipment. pointType is one of SENSOR, COMMAND, SETPOINT, ALARM, STATUS, PARAMETER. If your equipment is not in the graph yet, create elements first (POST /api/v1/buildings/{buildingId}/elements, which accepts spaceId and your own externalId + sourceSystem), or bulk-load them through POST /api/v1/buildings/{buildingId}/import/elements and …/import/points.
The two import/* routes are gated separately from the single-element route above: they require data-import.sync:read and data-import.sync:write, which only admin, manager and accountant carry. A credential outside those roles gets 403 PERMISSION_DENIED even when it is correctly scoped to the building — see the permission table below.
Send observations
POST /api/v1/buildings/{buildingId}/telemetry/observations{
"observations": [
{ "pointId": "…", "value": "21.4", "timestamp": "2026-08-04T09:12:00.000Z" },
{ "pointId": "…", "value": "on", "source": "bms-gateway" }
]
}valueis a string on the wire;timestampdefaults to now.- Every
pointIdmust already exist in this building — unknown ids fail the whole request with400listing them. There is no implicit point creation. - Numeric values are stored in the time-series database. Non-numeric values (
"on","fault") are not stored as a series — they still update the point's last value and still raise the live event, but they will not come back from a historical query. If you need history for a status, encode it numerically. - Ingest raises
building.point.updated, which is what makes readings arrive live in the UI and on webhooks.
Or send them over MQTT
If your monitoring system already speaks MQTT, tv-api can subscribe instead of you posting. Publish to the native topic:
tv/{orgId}/{siteId}/{buildingId}/telemetrywith the same body as the REST call — {"observations": [...]}. Messages are handed to exactly the same ingest path, so every rule above still applies: points must exist, non-numeric values are not stored as a series, and building.point.updated still fires.
MQTT is off unless the deployment turns it on
Ingestion only runs when the environment sets MQTT_ENABLED=true, and it needs broker credentials that are part of the deployment rather than something you mint yourself. Confirm with us that it is enabled for your environment before you build against it — otherwise your messages are published to a broker nobody is reading.
There is a second topic, rec/{deviceId}/observations for REC edge devices. It is parsed but not yet ingested — the handler logs the message and stops. Do not build on it; use the native topic or REST.
Read back with GET …/telemetry/observations?pointId=…&from=…&to=… (optional aggregation of avg|min|max|sum|count|last with an interval such as 5m, limit up to 10 000), or take a whole-building snapshot of last values with GET …/telemetry/latest.
Which permission you need
| Endpoint | Permission | Roles that carry it |
|---|---|---|
| Read layers and values | building.layers:read | user and above |
| Create layers, upload values | building.layers:write | manager, building-graph-writer |
| Bulk-import elements and points | data-import.sync:read + data-import.sync:write | admin, manager, accountant |
| Telemetry ingest and read | building-tenant scoped | any token scoped to the building's org |
For a partner credential the role to ask for is manager — it is the one role that carries every write in this guide. The second role in the layers-write row, building-graph-writer, is the platform's own IFC-ingestion identity: it bypasses the per-building ownership check by design and is not something an external integration should hold. See Getting access.