Skip to content

Into the twin

Two shapes of inbound data, with two different contracts:

You haveUseKeyed by
One current value per room — rent status, open-ticket count, CO₂, cleaning stateData layersThe room's identifier
A stream of readings over time — pressure, temperature, energyTelemetry observationsA 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

http
POST /api/v1/buildings/{buildingId}/data-layers
json
{
  "key": "fm-open-tickets",
  "name": "Открытые заявки",
  "description": "Open tickets per room, from the FM system",
  "valueType": "NUMBER"
}
FieldRules
keyRequired. URL-safe slug: ^[a-z0-9][a-z0-9-]{1,62}$. Unique per building — a repeat returns 409.
nameRequired. What the user sees in the layer list.
descriptionOptional.
valueTypeRequired, one of NUMBER, STRING, BOOLEAN, ENUM. ImmutablePATCH deliberately refuses to change it, because every stored value would become invalid. Need a different type? Create another layer.
valueSchemaOptional JSON Schema, compiled on create and checked against every value on upload. An invalid schema fails the create with 400.
legendOptional colour legend — see the callout below.
metadataOptional free-form object (we use {"synthetic": true} to mark demo layers).

2. Upload values

http
PUT /api/v1/buildings/{buildingId}/data-layers/{layerId}/values
json
{
  "keyBy": "externalId",
  "replace": false,
  "values": {
    "3lPQxG$0v9zRZ1mB7kYtE2": 4,
    "1aBcDe$FgHiJkLmNoPqRs": 0
  }
}
FieldMeaning
keyByexternalId (default) — the keys are IFC GlobalIds. id — the keys are internal room UUIDs. See Identity.
replacefalse (default) upserts only the rooms in this payload. true first deletes every value in the layer, so the payload becomes the complete state.
valuesAn 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:

json
{ "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:

reasonWhat it means
BUILDING_HAS_NO_SPACESThe building graph is empty — no model has been imported and no rooms were created.
BUILDING_HAS_NO_EXTERNAL_IDSThe rooms exist but carry no GlobalId, so keyBy: "externalId" can never match. Use keyBy: "id".
IDENTIFIERS_NOT_IN_BUILDINGBoth 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 429 with Retry-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 larger limit is rejected, not silently clamped — so a full read of a large layer is a loop, not one call.

3. Read values back

http
GET /api/v1/buildings/{buildingId}/data-layers/{layerId}/values?gte=3&limit=100
json
{
  "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:

  1. Write values in the language you want on screen"Занято", "Свободно". Simple, and correct as long as one language is enough.

  2. Send an explicit legend and 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) or min/max (inclusive range, numeric layers). A legend that matches nothing is treated as unusable and the derived colouring takes over — so a typo in value degrades 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

http
POST /api/v1/buildings/{buildingId}/points
json
{
  "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

http
POST /api/v1/buildings/{buildingId}/telemetry/observations
json
{
  "observations": [
    { "pointId": "…", "value": "21.4", "timestamp": "2026-08-04T09:12:00.000Z" },
    { "pointId": "…", "value": "on",   "source": "bms-gateway" }
  ]
}
  • value is a string on the wire; timestamp defaults to now.
  • Every pointId must already exist in this building — unknown ids fail the whole request with 400 listing 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}/telemetry

with 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

EndpointPermissionRoles that carry it
Read layers and valuesbuilding.layers:readuser and above
Create layers, upload valuesbuilding.layers:writemanager, building-graph-writer
Bulk-import elements and pointsdata-import.sync:read + data-import.sync:writeadmin, manager, accountant
Telemetry ingest and readbuilding-tenant scopedany 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.

Built on the Tango Vision platform. Questions? developers@tango.vision