These routes manage the asset tree of Hermetry Twin: sites, buildings, floors, rooms, lines, machines and your own kinds, where each device is placed, and the relations between assets and devices. The concepts are in Asset hierarchy. Paths are under https://<your host>/tenant/<tenantId>.
Routes
| Method | Path | Permission | Does |
|---|---|---|---|
| GET | /api/iot/assets | iot_asset.assets.read | List as a flat page, a tree, the Sites & Assets table or its summary |
| POST | /api/iot/assets | iot_asset.assets.manage | Create an asset |
| GET | /api/iot/assets/{assetId} | iot_asset.assets.read | One asset with its ancestors and counts |
| PATCH | /api/iot/assets/{assetId} | iot_asset.assets.manage | Change an asset |
| DELETE | /api/iot/assets/{assetId} | iot_asset.assets.manage | Delete an asset |
| POST | /api/iot/assets/{assetId}/move | iot_asset.assets.manage | Move under another parent |
| GET | /api/iot/assets/{assetId}/devices | iot_asset.assets.read | Devices placed on it (and below) |
| POST | /api/iot/assets/{assetId}/devices | iot_asset.relations.manage | Place up to 100 devices on it |
| GET | /api/iot/devices/{deviceId}/placement | iot_asset.assets.read | Where a device is |
| PUT | /api/iot/devices/{deviceId}/placement | iot_asset.relations.manage | Place a device, or remove its placement |
| GET | /api/iot/relations | iot_asset.assets.read | List relations |
| POST | /api/iot/relations | iot_asset.relations.manage | Create a relation |
| DELETE | /api/iot/relations/{relationId} | iot_asset.relations.manage | Delete a relation |
Default roles: reading is a User permission; changing assets, placements and relations is an Admin permission. Every change is written to the audit log (the changed field names, never the values).
The asset
| Field | Rules |
|---|---|
name | required, 1 to 255 characters |
kind | required; letters, digits and _, 1 to 24, stored in capitals. Suggested: SITE, BUILDING, FLOOR, ROOM, AREA, LINE, MACHINE, OTHER; any other is allowed |
code | letters, digits, ., _, -; 1 to 32; stored in capitals; unique among your live assets. Left out: made from the name. |
parentId | create only; null or absent for a top-level asset. Change it later with move. |
description | up to 1,000 characters |
lat, lng | −90 to 90, −180 to 180 |
timezone | an IANA name such as Europe/Istanbul |
addressJson | up to 12 text fields |
attributes | up to 100 keys and 16 KiB; keys start with a letter, then letters, digits, ., _, - (up to 64) |
defaultDashboardId | a dashboard the asset’s Open dashboard button opens |
Read-only fields in answers: iotAssetId, path (the codes from the top, for example /IST/BLD-A/GF), depth, createdAt, updatedAt.
POST/tenant/{tenantId}/api/iot/assets
Create an asset. Unknown fields are refused.
Request body application/json
{
"name": "Compressor room",
"kind": "ROOM",
"code": "CR1",
"parentId": "e5f6…",
"timezone": "Europe/Istanbul",
"attributes": {
"area_m2": 42
}
}
Example curl
curl -X POST 'https://<your-host>/tenant/{tenantId}/api/iot/assets' \
-H 'Content-Type: application/json' \
-d '{"name":"Compressor room","kind":"ROOM","code":"CR1","parentId":"e5f6…","timezone":"Europe/Istanbul","attributes":{"area_m2":42}}'
Responses
201Created
{
"asset": {
"iotAssetId": "77aa…",
"parentId": "e5f6…",
"path": "/IST/BLD-A/GF/CR1",
"depth": 4,
"code": "CR1",
"name": "Compressor room",
"kind": "ROOM",
"timezone": "Europe/Istanbul",
"attributes": {
"area_m2": 42
},
"…": "…"
}
}
409Conflict
{
"message": "Another asset already uses this code.",
"code": "CONFLICT",
"details": {
"reason": "code_taken"
}
}
PATCH takes the same fields except parentId. A new code rewrites the paths of every asset below in the same step.
GET /api/iot/assets/{assetId} adds ancestors (root first, each with id, code, name, kind), effectiveTimezone (its own, else the nearest ancestor’s), childCount and deviceCount.
List and tree
GET /api/iot/assets with view:
view | Answer | Parameters |
|---|---|---|
list (default) | { assets, total, page, pageSize }, a flat page | parentId (a UUID or root), subtreeOf, kind, search, includeDeleted, page from 1, pageSize up to 200 (default 50) |
tree | The nested tree | rootId, depth (default 6, at most 12); at most 5,000 assets |
table | One page of the Sites & Assets table: each asset with its place, children, devices and active alarms | search, kind, site (a subtree), parentId, alarmed=true, sort (name, kind, location, children, devices, alarms with :asc or :desc), page, pageSize |
summary | The counts and the sites with their alarms per severity |
Alarm figures are included only when the caller may read alarms.
Move
POST /api/iot/assets/{assetId}/move
{ "parentId": "c3d4…" }
null makes it a top-level asset. Everything below moves with it. Moving an asset under itself or one of its descendants is refused: “An asset cannot be moved under itself or one of its own descendants.” (400, cycle). A path may be at most 1,024 characters (too_deep).
Delete
An asset with child assets is refused: “This asset still has child assets: move or delete them first.” (409, has_children). If another module reports that it still uses the asset, the delete is refused with 409 naming that module. Deleting an asset ends its device placements and relations; the devices themselves stay.
Place devices
POST /api/iot/assets/{assetId}/devices
{ "deviceIds": ["6f1c…", "8b2d…"] }
Each device has at most one place: placing it here moves it from where it was. The answer { "changes": [...] } lists what moved. One device at a time:
PUT /api/iot/devices/{deviceId}/placement
{ "iotAssetId": "77aa…" } // or null to remove the placement
Both GET and PUT of .../placement answer { "placement": { "iotAssetId": "77aa…", "path": [ { "iotAssetId": "…", "code": "IST", "name": "Istanbul plant", "kind": "SITE" }, … ] } }, root first. A device with no place has iotAssetId: null.
GET /api/iot/assets/{assetId}/devices lists devices placed on the asset and, by default, below it (recursive=false for the asset alone): search, online=true|false, sort=name|connection:asc|desc, page from 1, pageSize up to 200. The answer is { items, total, page, pageSize }.
Relations
A relation links two ends, each an ASSET or a DEVICE, with a type: CONTAINS, MANAGES, FEEDS, MONITORS or your own (letters, digits, _, up to 32, stored in capitals).
POST/tenant/{tenantId}/api/iot/relations
Create a relation. additionalInfo holds up to 20 keys and 4 KiB.
Request body application/json
{
"fromType": "DEVICE",
"fromId": "8b2d…",
"toType": "DEVICE",
"toId": "6f1c…",
"type": "FEEDS",
"additionalInfo": {
"line": "DN150"
}
}
Example curl
curl -X POST 'https://<your-host>/tenant/{tenantId}/api/iot/relations' \
-H 'Content-Type: application/json' \
-d '{"fromType":"DEVICE","fromId":"8b2d…","toType":"DEVICE","toId":"6f1c…","type":"FEEDS","additionalInfo":{"line":"DN150"}}'
Responses
201Created
{
"relation": {
"relationId": "…",
"fromType": "DEVICE",
"fromId": "8b2d…",
"toType": "DEVICE",
"toId": "6f1c…",
"type": "FEEDS",
"…": "…"
}
}
409Conflict
{
"message": "This relation already exists.",
"code": "CONFLICT",
"details": {
"reason": "relation_exists"
}
}
Rules:
CONTAINSfrom an asset to a device is the device’s placement (use the placement routes).CONTAINSin any other shape is refused (placement_shape), and an asset inside another asset is set by moving it, not by a relation (parentage_is_move).- A relation needs two different ends (
self_relation). - A missing end is
404(end_not_found).
GET /api/iot/relations?entityType=ASSET&entityId={assetId} lists the relations of one end in both directions; or filter by fromType, fromId, toType, toId, type. limit 1 to 500 (default 200). The answer is { "relations": [...] }.
Search devices for a picker
GET /api/iot/devices/search?q=pump&limit=20&near=iot_asset:{assetId} answers { "devices": [{ "id", "name", "code", "hint" }] }: at most 20 devices by name, those of the given device or asset first, with hint naming where each is placed. It needs both iot_asset.assets.read and iot_device.devices.read.

