Loopshore API
API Reference
Version 0.67.0
Introduction
Loopshore API contains interfaces for reading and writing environmental sensor data from and to Loopshore cloud service.
APIs are HTTP based REST APIs. Only HTTPS is supported.
Terminology
Observation – a Physical or logical set of value, quantity and timestamp. Usually a measurement result.
Webhook – a way to deliver device observations to 3rd party servers.
API location
| Property | Value |
|---|---|
| Base URL | https://service.loopshore.com/api |
| Port | 443 |
All endpoint paths in this document are relative to the base URL. For example, user/{user-id} means the full path is https://service.loopshore.com/api/user/{user-id}.
Authentication
There are four ways to authenticate to the API.
- Session cookie (web applications)
- Bearer token (mobile and programmatic clients)
- API keys
- Custom ingestion URL (only for sending observations)
Session cookie (web)
You can authenticate to the API using username and password. Obtaining these credentials is out of scope of this document. Workflow is as follows:
- Post credentials to /token API
- Save received session cookie
- The cookie is automatically sent with subsequent requests
Bearer token (mobile)
Mobile and programmatic clients use short-lived access tokens with long-lived refresh tokens. Workflow is as follows:
- Post credentials to /token API with
grant_typeset to"mobile" - Receive an access token (15-minute lifetime) and a refresh token (30-day lifetime) in the response body
- Use the access token in the
Authorizationheader:Authorization: Bearer <access_token> - When the access token expires, use the refresh token at /token/refresh to obtain a new token pair
- Store the refresh token securely (e.g., device keychain/keystore)
Custom ingestion URL
Ask URL from Loopshore.
API keys
Users can use their credentials to acquire an API key that has the same privileges as the username and password. Using an API key might be easier, faster and less resource consuming than acquiring the session key for each request. Note that you have to handle the API keys with the same precautions as you handle the password.
A key can optionally be created as read-only. A read-only key keeps the same read access as its owner but cannot modify anything: every mutating request (any HTTP method other than GET/HEAD/OPTIONS) is rejected with 403 Forbidden. Read-only keys are recommended for integrations that only need to read data, so a leaked key cannot be used to change anything.
Authorization
Each user may access only what their roles give them, at the locations where the roles are granted; see User Roles. If you try to access a resource you don’t have access to, the API returns 403 Forbidden (or 404 Not Found where it does not reveal whether the resource exists). 401 Unauthorized means only that the request has no valid authentication; see Error handling. Ask the person who manages your user account, or Loopshore, if you think that you should have access to a resource.
Restrictions
A reasonable usage of APIs is expected from API users. Currently reasonable usage limit is once/minute/device/day, which should cover all the normal use cases. API access may be temporarily blocked if the user exceeds this amount.
If you want to exceed this restriction, contact Loopshore.
Concurrent history requests
Reading observation history is the heaviest work the API does, so the service accepts only a few history requests from one user account at a time, counted over all its API keys and sessions. This applies to Observation history, both cursor-based histories, every downsampled observations endpoint and Aggregated observations. A request over the limit waits a while for its turn before it is answered, so a client that sends its history requests one or two at a time never notices the limit.
When the service cannot take a request, it answers 429 Too Many Requests with {"error": "too_many_requests"} and a Retry-After header giving the seconds to wait. Wait at least that long and send the request again; do not retry in a tight loop.
A history read that takes too long is stopped and answered 504 Gateway Timeout with {"error": "query_timeout"}. Narrow the request, a shorter time window or a single quantity, and send it again.
Example 1
User has access to two devices, that each report new measurements every 10 minutes. User polls the latest measurements for each device every 5 minutes.
API Usage
- Devices: devices×hours×hourly_rate = 2×24×(60/10) = 288
- History: devices×hours×hourly_rate = 2×24×(60/5) = 576
- Total: 288+576 = 864 requests/24h
- Limit: devices×hours×minutes = 2×24×60 = 2880 requests/24h
24h requests (864) is well below the limit (2880) so API usage is ok.
Example 2
User has access to two devices, that each report new measurements every 10 minutes. User wants to load a year’s worth of history data from both devices. User fetches 15 different quantities from each device.
API Usage
- Devices: devices×hours×hourly_rate = 2×24×(60/10) = 288
- History:
- Measurement in a API call (5000 limit): (5000/15) = 333
- Hours in API call: 333/(60/10) = 56
- API calls needed to get 1 year history: (365×24)/56 = 158
- Total: 288+158 = 446 requests
- Limit: devices×hours×minutes = 2×24×60 = 2880
Requests (446) is below the limit (2880) so API usage is ok.
API descriptions
Content types
APIs are HTTP REST APIs. APIs accept two types of content: JSON and transit+JSON. If user does not give Content-Type (in POST) or Accept header (in GET), service assumes JSON.
Error handling
The API uses standard HTTP status codes to indicate success or failure:
| Status Code | Meaning |
|---|---|
| 200 | Success with response body |
| 204 | Success with no content |
| 400 | Bad request - invalid parameters or validation failure |
| 401 | Unauthorized - missing or invalid authentication |
| 403 | Forbidden - insufficient permissions, a read-only API key used for a change, or a suspended user or organization |
| 404 | Not found - resource doesn’t exist or isn’t accessible |
| 429 | Too many requests - wait for the seconds in Retry-After, then send the request again; see Restrictions |
| 504 | Gateway timeout - a history read took too long; narrow it and send it again; see Restrictions |
A refused request answers 404 when the resource doesn’t exist or is outside what you can see, and 403 when you can see it but aren’t allowed the action. This applies to locations, groups, devices, installations and users.
Error responses are returned as JSON. The format varies but typically includes an error or message field:
{
"error": "Error type",
"message": "Detailed description"
}Common error examples:
Authentication missing, expired or invalid (no session, an expired or invalid token, or an unknown API key) returns 401 on every endpoint that requires authentication, with a WWW-Authenticate: Bearer header. The body is empty, except for an unknown API key, which returns {"error": "invalid_api_key"}. A request with an x-api-key header is authenticated by that key only: if the key is unknown, a session cookie or Bearer token in the same request is not used. A 401 never means that you lack permission for the resource; that is a 403.
Validation failure (e.g., creating user with invalid data):
{"error": "Bad Request", "message": "Unsupported language"}Resource not found or outside your scope:
"No such user"Access to the service has ended for the user’s organization (all authenticated requests, login and token refresh):
{"error": "group_suspended"}The user has been suspended (see Suspend user) (all authenticated requests, login and token refresh):
{"error": "user_suspended"}A share link no longer works (all requests made with it). This is the only error a share link gives for its location being deleted or archived, the location being outside what the link’s owner can see, or the owner or their organization being suspended:
{"error": "share_unavailable"}Duplicate username:
{"error": "Conflict", "message": "Username already exists"}Invalid path format when creating location:
{"error": "Bad Request", "message": "Path must match pattern: label.label.label"}Error handling guidance:
- Check the status code first, then parse the response body for details
- Response body may be a JSON object or a plain string
- For 401 errors, re-authenticate (or refresh the access token) and retry once
- For 403 with
{"error": "group_suspended"}, don’t re-authenticate or retry: the organization’s access to the service has ended, and it stays blocked until Loopshore restores it - For 403 with
{"error": "user_suspended"}, don’t re-authenticate or retry: the user has been suspended, and it stays blocked until someone who manages the user unsuspends them - For 403 with
{"error": "share_unavailable"}, don’t retry: tell the viewer that the link no longer works. It works again only if its owner regains access to the shared location - For 400/403/404 errors, fix the request (don’t retry)
- For 5xx errors, retry with exponential backoff
Known quantities
APIs are designed so that you can practically send any kind of information/observations to the system and fetch those via APIs. There is however, some predefined and known data types that have special meaning. These should be used when possible, because then for example the user interface recognizes the types.
Quantities and units are case sensitive.
Some quantities are calculated by system if not sent by device and source data is available. These are marked with asterisk (*) in the list below.
| Quantity | Unit | Datatype | Description |
|---|---|---|---|
| temperature | C | Number | Temperature |
| humidity | % | Number | Relative humidity |
| tvoc | ppb | Number | Total Volatile Organic Compounds |
| voc_index | index | Number | VOC Index (relative air quality indicator, 1-500) |
| co2 | ppm | Number | Carbon Dioxide |
| pm1p0 | ug/m3 | Number | Particles < 1.0um |
| pm2p5 | ug/m3 | Number | Particles < 2.5um |
| pm4p0 | ug/m3 | Number | Particles < 4.0um |
| pm10p0 | ug/m3 | Number | Particles < 10um |
| pressure | hPa | Number | Atmospheric pressure |
| light | lx | Number | Ambient light level |
| laeqx | dB | Number | A-weighted equivalent ambient sound pressure level since last measurement of the same type |
| lafmin | dB | Number | A-weighted minimum sound pressure level since last measurement of the same type |
| lafmax | dB | Number | A-weighted maximum sound pressure level since last measurement of the same type |
| lzeqx | dB | Number | Z-weighted equivalent ambient sound pressure level since last measurement of the same type |
| lzfmin | dB | Number | Z-weighted minimum sound pressure level since last measurement of the same type |
| lzfmax | dB | Number | Z-weighted maximum sound pressure level since last measurement of same type |
| pressure_diff | Pa | Number | Pressure difference |
| pressure_diff_min | Pa | Number | Minimum pressure difference since last measurement of the same type |
| pressure_diff_max | Pa | Number | Maximum pressure difference since last measurement of the same type |
| accxposmax | m/s2 | Number | Maximum acceleration along X-Axis in positive direction since last measurement of the same type |
| accxnegmax | m/s2 | Number | Maximum acceleration along X-Axis in negative direction since last measurement of same type |
| accyposmax | m/s2 | Number | Maximum acceleration along Y-Axis in positive direction since last measurement of same type |
| accynegmax | m/s2 | Number | Maximum acceleration along Y-Axis in negative direction since last measurement of same type |
| acczposmax | m/s2 | Number | Maximum acceleration along Z-Axis in positive direction since last measurement of same type |
| accznegmax | m/s2 | Number | Maximum acceleration along Z-Axis in negative direction since last measurement of same type |
| latitude | d | Number | GPS coordinate in degree/angle |
| longitude | d | Number | GPS coordinate in degree/angle |
| altitude | m | Number | Altitude from sea level in meters |
| shock | m/s2 | Number | Acceleration shock |
| tvoc_density | ug/m3 | Number | Total Volatile Organic Compounds |
| absolute_humidity* | g/m3 | Number | Absolute humidity |
| dewpoint* | C | Number | Dewpoint |
| weight | kg | Number | Weight of something |
| co | ppm | Number | Carbon monoxide |
| radon | Bq/m3 | Number | Radon level |
| pn0p5 | #/m3 | Number | Number of particles 0.5–10 µm |
| pn1p0 | #/m3 | Number | Number of particles 1.0–10 µm |
| pn2p5 | #/m3 | Number | Number of particles 2.5–10 µm |
| pn4p0 | #/m3 | Number | Number of particles 4.0–10 µm |
| pn0p3 | #/m3 | Number | Number of particles 0.3–10 µm |
| tps | um | Number | Typical particle size |
| batt | % | Number | Battery level |
| rsrp | dBm | Number | Reference Signal Received Power (cellular signal strength) |
| snr | dB | Number | Signal to Noise Ratio |
| uptime | s | Number | Device uptime |
| power_control | Number | Power control state | |
| full_tx_power | s | Number | Full transmit power duration |
Note! System also recognizes quantities that are post fixed with _<number>. So you can for example send three temperatures from the same device by naming them temperature, temperature_1 and temperature_1234.
User APIs STABLE
These APIs are available to all authenticated users for reading and writing observation data. Each returns only what your roles give you (see User Roles).
Login STABLE
| Method | Location |
|---|---|
| POST | token |
| Body | Optional | Datatype | Description |
|---|---|---|---|
| name | no | String | Username |
| password | no | String | Password |
| grant_type | yes | String | "cookie" (default) or "mobile" |
| device_info | yes | String | Device description (e.g. "iPhone 15") — only used with grant_type: "mobile" |
The login endpoint supports two modes depending on the grant_type parameter.
Cookie mode (default)
When grant_type is omitted or "cookie", the response sets a session cookie. This is the mode used by web applications.
Example request
{
"name": "user@example.com",
"password": "secret"
}Example response header
Set-Cookie: jabster_token=<token>;Max-Age=43200
Example use of cookie in a request
Cookie: jabster_token=<token>
Mobile mode
When grant_type is "mobile", the response returns access and refresh tokens in the body. No cookie is set.
Example request
{
"name": "user@example.com",
"password": "secret",
"grant_type": "mobile",
"device_info": "iPhone 15 Pro"
}Example response
{
"user": "user@example.com",
"ts": "2025-01-15T10:30:00Z",
"access_token": "eyJhbGciOiJIUzUxMiJ9...",
"refresh_token": "a1b2c3d4e5f6...",
"token_type": "Bearer",
"expires_in": 900
}Response fields (mobile mode):
| Field | Type | Description |
|---|---|---|
user |
string | Username |
ts |
string | Login timestamp (ISO 8601) |
access_token |
string | Short-lived JWT access token (15 minutes) |
refresh_token |
string | Long-lived opaque refresh token (30 days) |
token_type |
string | Always "Bearer" |
expires_in |
integer | Access token lifetime in seconds (900) |
Example use of access token in a request
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...
Error responses:
| Status | Body | Description |
|---|---|---|
| 403 | "Invalid credentials." |
Wrong username or password |
| 403 | {"error": "group_suspended"} |
Correct credentials, but the organization’s access to the service has ended. Returned only after a correct password. |
| 403 | {"error": "user_suspended"} |
Correct credentials, but the user has been suspended. Returned only after a correct password. |
Refresh token STABLE
| Method | Location |
|---|---|
| POST | token/refresh |
| Body | Optional | Datatype | Description |
|---|---|---|---|
| refresh_token | no | String | The refresh token received from login or a previous refresh |
Exchange a valid refresh token for a new access token and refresh token pair. This endpoint does not require authentication — the refresh token itself serves as the credential.
Token rotation: Each refresh request invalidates the old refresh token and issues a new one. Always store and use the latest refresh token.
Reuse detection: If a previously rotated (invalidated) refresh token is used, all tokens in the session are revoked for security. The user must log in again.
Example request
{
"refresh_token": "a1b2c3d4e5f6..."
}Example response
{
"access_token": "eyJhbGciOiJIUzUxMiJ9...",
"refresh_token": "f6e5d4c3b2a1...",
"token_type": "Bearer",
"expires_in": 900
}Error responses:
| Status | Body | Description |
|---|---|---|
| 401 | {"error": "invalid_refresh_token"} |
Token not found, expired, or already rotated |
| 401 | {"error": "token_reuse_detected"} |
Reuse of a rotated token — all session tokens revoked |
| 403 | {"error": "group_suspended"} |
The organization’s access to the service has ended. The refresh token is not consumed, so it works again if access is restored. |
| 403 | {"error": "user_suspended"} |
The user has been suspended. The refresh token is not consumed, so it works again once the user is unsuspended. |
Acquire apikey STABLE
| Method | Location |
|---|---|
| POST | api_key |
| Body | Optional | Datatype |
|---|---|---|
| key-name | no | String |
| read-only | yes | Boolean |
| purpose | yes | String |
| context | yes | JSON |
Acquire an apikey (random hexstring) that can be used instead of session style login. key-name is a documentary string that can be used to identify different keys. Purpose and context should be omitted.
Set read-only to true to create a key that can read data but cannot perform any modifications (see API keys). It defaults to false. A read-only key still has the same read access as its owner.
Response contains a secret-key that can be used in HTTP header to authenticate a user. Treat the secret-key with the same precautions as a password.
There is no way to recover a lost secret-key. In case a secret-key is lost, make a new one and preferably delete the lost key from the service.
id is used to identify a key in other APIs.
Example request
{
"key-name": "my-read-only-key",
"read-only": true
}Example response
{
"id": 123,
"secret-key": "ccb22ee3fe90ce30d20654ee214307755806f53dbb84",
"read-only": true
}Notes:
- The purpose and context request fields should be omitted. They refer to restricted access modes that are not relevant for the APIs documented here.
apikey usage
Apikey is used by putting the key to HTTP header with name x-api-key.
Example header
x-api-key:ccb22ee3fe90ce30d20654ee214307755806f53dbb84
List apikeys STABLE
| Method | Location |
|---|---|
| GET | api_key |
List all the user’s api-keys with key-name and id. Each entry also includes read_only, indicating whether the key is restricted to read-only access.
Example response
[{
"id": 123,
"key-name": "my-api-key",
"purpose": "all",
"read_only": false
},
{
"id": 567,
"key-name": "my-iframe-view-key",
"purpose": "sharer",
"context": {"any": "thing"},
"read_only": false
},
{
"id": 891,
"key-name": "my-read-only-key",
"purpose": "all",
"read_only": true
}]Remove apikey STABLE
| Method | Location |
|---|---|
| DELETE | api_key |
| Body | Optional | Datatype |
|---|---|---|
| id | no | Number |
Removes an api-key from the service.
Example request
{
"id": 123
}Observation history STABLE
| Method | Location |
|---|---|
| GET | observation/read/device/{device-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
| start | no | query | String (RFC3339) |
| end | yes | query | String (RFC3339) |
| quantity | yes | query | String |
Returns all observations for given device-id from the start timestamp to current time.
Result is limited to 5000 observations.
Optionally, end timestamp and quantity filter can be given.
start time is inclusive and end time is exclusive. Both are points in time, so a UTC offset is applied: end=2021-03-01T00:00:00+02:00 ends the window at 22:00 UTC on 28 February. Encode the + as %2B in the URL.
Observations are ordered by timestamp, and observations with the same timestamp by quantity. When the result reaches the 5000-observation limit, the observations at its last timestamp may be cut short. Page by fixed time windows that stay under the limit, as in the usage example below. Starting the next request from the last returned timestamp can repeat or miss observations.
Access. You receive observations from the periods the device was installed at a location you have access to. An org-admin instead receives observations from the periods the device belonged to their organization, wherever it was installed. Outside these periods the result is an empty list.
Result is a JSON list of objects where each object presents a single quantity-value-time observation. Timestamp, value and quantity are always present. Unit is optional.
Response example
[
{
"timestamp": "2021-02-03T11:29:31.229Z",
"value": 21.4,
"quantity": "temperature",
"unit": "C"
},
{
"timestamp": "2021-02-03T11:23:12Z",
"value": 442,
"quantity": "co2",
"unit": "PPM"
}
]Usage example
You want to get one year history of temperature from a device. You know that a device sends data mostly with once / 10 minute intervals. This means that you get 6×24=144 datapoints in a day and 144×30=4320 datapoints in a month. This falls within API 5000 result rows limit.
Because start time is inclusive and end time is exclusive, you can now call the API like this:
/read/device/my-device-id?start=2020-01-01T00%3A00%3A00Z&quantity=temperature&end=2020-02-01T00%3A00%3A00Z
/read/device/my-device-id?start=2020-02-01T00%3A00%3A00Z&quantity=temperature&end=2020-03-01T00%3A00%3A00Z
Latest observations STABLE
| Method | Location |
|---|---|
| GET | observation/read/device/{device-id}/last-values |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
Returns the latest observations of each quantity for given device-id.
This API is optimized for “poll latest observations” use case.
The same access rule as in Observation history applies. A device currently installed at an archived location returns an empty list.
Response example
[
{
"timestamp": "2021-02-03T11:29:31.229Z",
"value": 21.4,
"quantity": "temperature",
"unit": "C"
}
]Subscribe observations STABLE
| Method | Location |
|---|---|
| POST | webhook |
| Body | Optional | Datatype |
|---|---|---|
| url | no | String (valid URL, HTTP or HTTPS) |
| device-id | yes | String (either device-id or scope required) |
| scope | yes | String (either device-id or scope required) |
| x-headers | yes | JSON object |
| description | yes | String |
Posts a new webhook either given device-id or scope.
Webhooks or subscriptions are used to subscribe data to 3rd party server in real time. When observations from device arrive to Loopshore service, it immediately forwards them to all interested stakeholders.
Webhook must contain a valid url where the data is to be sent.
Webhooks can be targeted to single device-ids or all the devices that user has access to (scope). Only valid value for scope is “all”. “all” covers every device installed at a location where you hold a tier from viewer up; for an org-admin, every location of their organization. Observations from devices at archived locations are not sent.
Optionally HTTP message sent from Loopshore can contain custom headers (x-headers).
Simple example for single device
{
"url": "http://my-insecure-server.io/my-api",
"device-id": "my-device-id"
}More complex example for all devices with custom headers
{
"url": "https://my-secure-server.io/my-api",
"scope": "all",
"description": "Just for testing, to be deleted",
"x-headers": {
"apikey": "secret",
"target": "all-loopshore-devices"
}
}Response
Response contains all the data that was posted to API plus id and nonce fields.
| Body | Optional | Datatype |
|---|---|---|
| id | no | String (UUID) |
| nonce | no | String |
id - can be used to identify this particular webhook in other APIs.
nonce - is a random string that is sent along with forwarded observations and it can be used to identify what hook generated the POST.
Example response
{
"id": "a0c03d18-378e-4326-b1d5-b47d9248be1e",
"url": "https://my-secure-server.io/my-api",
"scope": "all",
"description": "Just for testing, to be deleted",
"nonce": "_izF930v",
"x-headers": {
"apikey": "secret",
"target": "all-loopshore-devices"
},
"device-id": null
}List subscriptions STABLE
| Method | Location |
|---|---|
| GET | webhook |
This API can be used to list all the subscriptions/webhooks.
Example response
[{
"id": "a0c03d18-378e-4326-b1d5-b47d9248be1e",
"url": "https://my-secure-server.io/my-api",
"scope": "all",
"description": "Just for testing, to be deleted",
"nonce": "_izF930v",
"x-headers": {
"apikey": "secret",
"target": "all-loopshore-devices"
},
"device-id": null,
"last-delivered-at": "2024-06-01T00:00:00Z"
}]last-delivered-at – last time an observation was delivered to the url successfully, or null if never recorded. Updated at most once a day, so it can lag by up to a day.
Cancel observation subscription STABLE
| Method | Location |
|---|---|
| DELETE | webhook/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | String (UUID) |
This API can be used to remove a subscription.
Test subscriptions STABLE
| Method | Location |
|---|---|
| POST | webhook/{id}/test |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | String (UUID) |
| Body | Optional | Datatype |
|---|---|---|
| nonce | no | String |
| data | no | JSON object |
Sends a test message to webhook url.
You can test your webhook counterpart by posting a custom message to this API. Service then forwards the message to your server.
nonce – is the nonce for the webhook id.
data – any valid JSON. When real message arrives, data contains the same data that came in to the system via Send observations API.
Example message sent to url server
{
"nonce": "_izF930v",
"data": {"my": "test data"}
}Example of message identical to real data
{
"nonce": "_izF930v",
"data": {
"device-id": "123456789",
"observations": [
{
"unit": "C",
"value": 19.24,
"quantity": "temperature",
"timestamp": "2021-02-03T11:23:12Z"
}
]
}
}Send observations STABLE
| Method | Location |
|---|---|
| POST | observation/{token}/v2 |
| Body | Optional | Datatype |
|---|---|---|
| device-id | no | String |
| observations | no | List (observation Object) |
| observation.timestamp | no | String (RFC3339) |
| observation.quantity | no | String |
| observation.value | no | Number/String |
| observation.unit | no | String |
Posts new observations for given device-id. For getting your own token, contact Loopshore.
Body example
{
"device-id": "my-device-id",
"observations": [
{
"timestamp": "2021-02-03T11:29:31.229Z",
"value": 21.4,
"quantity": "temperature",
"unit": "C"
}
]
}Extended User APIs DRAFT
These APIs are available to all authenticated users, and each returns only what your roles give you (see User Roles). Reading needs only that you see the location; an endpoint that needs more says so on its Required role line. They extend the stable APIs above with additional features for managing user preferences, browsing locations, reading measurement data, configuring alarms, and more.
Self-Service DRAFT
Get current user DRAFT
| Method | Location |
|---|---|
| GET | whoami |
Returns the profile data of the currently authenticated user.
Example response
{
"id": 1,
"name": "user@example.com",
"role": "user",
"email": "user@example.com",
"emailConfirmed": true,
"language": "fi",
"theme": "loopshore",
"profile": {},
"phonenum": "+358501234567",
"lastlogin": "2024-06-01T10:30:00Z",
"last-credential-login-at": "2024-05-28T08:00:00Z",
"last-refresh-at": "2024-06-01T10:30:00Z",
"features": {"ai-helper": false},
"permissions": {
"locations": [
{
"location-id": 5,
"path": "root.customer.hq",
"permissions": ["alarm/subscribe-status", "derived-quantity/manage", "location/display-settings",
"location/layout", "location/rename", "location/view", "note/write",
"personal/api-keys", "personal/share-links", "personal/webhooks"]
}
]
}
}Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | User ID |
name |
string | Username |
role |
string | user. Use permissions for what you may do (Removed user fields). |
email |
string | Email address |
emailConfirmed |
boolean | Whether email has been verified |
language |
string | UI language code (2-char: en, fi, sv) |
theme |
string | Theme identifier: the organization’s theme, see Theme |
profile |
object | User preferences; see Profile under Key Concepts. For a share link, the sharer’s, without their tree preference. |
phonenum |
string | Verified phone number in international format |
phonenum-unverified |
string | Phone number pending verification (present only during verification) |
lastlogin |
datetime | null | Latest activity timestamp — the greater of last-credential-login-at and last-refresh-at. null if neither has occurred. |
last-credential-login-at |
datetime | null | Last time the user completed a username/password login (web or mobile). null if never. |
last-refresh-at |
datetime | null | Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started. |
features |
object | Paid features you can use. ai-helper is true when the AI helper is available to you; always false for a share link. You get the AI helper while your organization has it, unless your own setting is off. See Set user AI helper setting. |
permissions |
object | What you may do, and where. See Permissions below. |
group-access-ends-at |
datetime | When the organization’s access to the service ends. Present only when an end date has been set. From that moment on, all requests by the organization’s users, including login, token refresh and API keys, return 403 {"error": "group_suspended"}. |
Users who manage users (org-admin, or building-admin at some location) additionally receive last-device-info (the device_info string from the most recent mobile session); others don’t. Get user has the full shape. The user management endpoints also count API key use, webhook deliveries and alarms sent in lastlogin; this endpoint does not.
Permissions
permissions lists what you may do, per scope. Use it to decide which actions to offer; the API still checks every request. role doesn’t tell what you may do.
| Field | Type | Description |
|---|---|---|
group |
object | Organization-wide permissions, present only for an org-admin. roots are the paths of the organization’s top locations; permissions apply at and below each of them. |
locations |
array | Location permissions: location-id, path and permissions. Each applies at the location and everything below it. |
share? |
boolean | true for a share link. Its permissions apply at the shared location only. |
share |
object | Present only for a share link: location-id, the shared location, and mode, read or write. A read link views the location; a write link also writes notes and changes display settings, where the link’s owner may. |
read-only? |
boolean | true for a read-only API key. Only reading permissions are listed. |
At an archived location only location/edit applies. Permission names:
| Permission | Scope | Allows |
|---|---|---|
location/view |
location | Seeing the location and its measurements |
location/rename |
location | Renaming the location |
location/edit |
location | Editing, archiving and adding locations below it |
location/manage-access |
location | Managing who can access the location |
location/display-settings |
location | Graph settings and weather display |
location/layout |
location | Location image and blueprint coordinates |
location/create-top-level |
organization | Creating top-level locations |
group/view |
organization | The organization’s users, top locations and devices |
device/configure |
location | Configuring device settings |
device/install |
location | Installing, removing and moving devices |
device/view-inventory |
organization | All of the organization’s devices, installed or not |
device/view-diagnostics |
organization | Device problems |
alarm/subscribe-quantity |
location | Alarms on measured quantities |
alarm/subscribe-status |
location | Device-status alarms (no data, battery, signal) |
alarm/edit-thresholds |
location | Editing alarm thresholds |
user/manage |
location | Managing users |
user/impersonate |
location | Impersonating users |
note/write |
location | Writing notes |
derived-quantity/manage |
location | Managing derived quantities |
survey/manage |
location | Managing surveys |
branding/manage |
organization | Editing the organization’s theme |
ai-helper/use |
organization | Reserved; use features.ai-helper |
personal/webhooks |
location | Own webhooks |
personal/api-keys |
location | Own API keys |
personal/share-links |
location | Own share links |
New permissions may be added; ignore names you don’t know.
Logout DRAFT
| Method | Location |
|---|---|
| DELETE | token |
| Body | Optional | Datatype | Description |
|---|---|---|---|
| refresh_token | yes | String | Refresh token to revoke (mobile clients) |
Clears the session cookie and logs the user out. Returns "logout" on success.
Mobile clients should include the refresh_token in the request body to revoke it server-side. If omitted, only the cookie is cleared (web logout behavior).
Example request (mobile)
{
"refresh_token": "a1b2c3d4e5f6..."
}Set language DRAFT
| Method | Location |
|---|---|
| PUT | whoami/language |
| Body | Optional | Datatype |
|---|---|---|
| language | no | String (2-char code: en, fi, sv) |
Set the UI language preference for the current user. The setting is synced — the same language applies on web and mobile.
Example request
{
"language": "fi"
}Success response: Returns the full user object (same as GET whoami).
Update settings DRAFT
| Method | Location |
|---|---|
| PUT | whoami/settings |
| Body | Optional | Datatype |
|---|---|---|
| temperature-unit | yes | String (celsius, fahrenheit) |
| weight-unit | yes | String (kilogram, pounds) |
| pm-unit | yes | String (ug/m3, mg/m3) |
| wind-speed-unit | yes | String (m/s, km/h, mph) |
| precipitation-unit | yes | String (mm, inch) |
| pressure-unit | yes | String (hPa, inHg) |
| tree-locations | yes | Integer[] or null |
Update display preferences: units, and the measurements tree preference tree-locations (see Profile). All fields are optional — only include fields you want to change; the others stay. Settings are synced across web and mobile. A value that can’t be stored answers 400.
Example request
{
"temperature-unit": "celsius",
"pressure-unit": "hPa"
}Success response: Returns the full user object (same as GET whoami).
Set phone number DRAFT
| Method | Location |
|---|---|
| PUT | whoami/phonenum |
| Body | Optional | Datatype |
|---|---|---|
| phonenum | no | String (international format, e.g. +358501234567) |
Set or update the user’s phone number. Phone number must be in international format starting with +. Send an empty string "" to remove the phone number.
If the phone number is new, SMS verification is required. A 6-digit verification code is sent via SMS.
Example request
{
"phonenum": "+358501234567"
}Example response
{
"verification-needed": true
}If verification-needed is true, call the verify endpoint with the code received via SMS.
Verify phone number DRAFT
| Method | Location |
|---|---|
| PUT | whoami/phonenum-verify |
| Body | Optional | Datatype |
|---|---|---|
| phone-num-verification-code | no | Integer (6-digit code, e.g. 123456) |
Verify the phone number using the 6-digit code received via SMS. Returns 200 on success, 400 if the code is incorrect.
Example request
{
"phone-num-verification-code": 123456
}Request password reset DRAFT
| Method | Location |
|---|---|
| POST | password/reset-request |
| Body | Optional | Datatype |
|---|---|---|
| no | String |
Request a password reset link to be sent to the given email address. This is a public endpoint — no authentication required. The reset token is short-lived (1 hour) since the user is expected to act on the email immediately, and repeated requests for the same account are rate-limited. The response is always the same regardless of whether the email exists, to prevent user enumeration. For reset emails sent by someone who manages the user, with longer-lived tokens,, see POST password/send-reset-email under Admin APIs.
Example request
{
"email": "user@example.com"
}Example response
{
"message": "If an account with that email exists, a password reset link has been sent."
}Location Tree & Details DRAFT
Locations are organized hierarchically: site → building → floor → room. The tree API returns all locations the current user has access to, with their children nested recursively. See the Path and Tag sections under Admin APIs for details on the hierarchy model.
Get location tree DRAFT
| Method | Location |
|---|---|
| GET | location/tree/my-locations |
Returns all locations that the current user has access to, organized as a recursive tree structure. Each node contains its children, forming the complete hierarchy from top-level sites down to individual rooms. Archived locations are excluded from this response.
The tree starts from the locations selected for your user. If none of the selected locations is available to you (for example, none is selected, or all of them are archived), the tree contains every location you can view.
For a share link, the tree is the shared location alone, with no children: a share link reaches only that location.
Example response
[
{
"id": 5,
"path": "campus.office",
"name": "Office Building",
"data": {},
"uuid": "550e8400-e29b-41d4-a716-446655440001",
"tag": "building",
"parent": 1,
"images": ["facade"],
"children": [
{
"id": 10,
"path": "campus.office.floor_1",
"name": "Floor 1",
"data": {},
"uuid": "550e8400-e29b-41d4-a716-446655440002",
"tag": "floor",
"parent": 5,
"images": ["blueprint"],
"children": [
{
"id": 15,
"path": "campus.office.floor_1.room_101",
"name": "Meeting Room",
"data": {},
"uuid": "550e8400-e29b-41d4-a716-446655440003",
"tag": "room",
"parent": 10,
"images": [null],
"children": []
}
]
}
]
}
]Node fields:
| Field | Type | Description |
|---|---|---|
id |
integer | Location ID |
path |
string | Hierarchical path (dot-separated) |
name |
string | Display name |
data |
object | Metadata (see Data section under Admin APIs) |
uuid |
string | UUID for external references |
tag |
string | Node type (building, staircase, floor, room) |
parent |
integer | Parent location ID (null for top-level) |
images |
string[] | Tags of uploaded images for this location (facade, blueprint). Returns [null] when no images exist. Use this to decide whether to call GET location/{id}/image?tag=facade — see the Location images subsection under Location Customization. |
children |
array | Child nodes (recursive) |
Get location details DRAFT
| Method | Location |
|---|---|
| GET | location/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
Get details for a single location. Returns the same fields as a tree node but without parent and children. Returns 404 for archived locations.
Example response
{
"id": 5,
"path": "campus.office",
"name": "Office Building",
"data": {"address": "Example Street 1"},
"uuid": "550e8400-e29b-41d4-a716-446655440001",
"tag": "building",
"images": ["facade"]
}The images field has the same meaning as on the tree endpoint above — see the images row in the Node fields table.
List devices at location DRAFT
| Method | Location |
|---|---|
| GET | location/{id}/devices |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
List devices currently installed at the given location. Returns only the most recent installation per device.
product-name is the human-readable product name (e.g. "Loop One") resolved server-side from the first two characters of device-type. It is null when the prefix is not in the product catalog (e.g. legacy or third-party devices).
Example response
[
{
"description": "Air quality monitor",
"device-id": "device-001",
"device-type": "L103EWC0301",
"product-name": "Loop One",
"installation-time": "2024-01-15T10:30:00Z"
}
]Reading Observation Data DRAFT
The stable APIs above cover basic observation reads (history with 5000-row limit and latest values per device). This section documents additional ways to read observation data that are more suitable for graphing, analytics, and large time ranges.
Three approaches to reading observation data:
- Raw reads — Return individual observations. Use the stable Observation history API for small time ranges (up to 5000 rows), or the cursor-based streaming API below for large ranges.
- Downsampled reads — Return a fixed number of data points spread across a time range. Ideal for rendering line charts without sending millions of points to the client.
- Aggregated reads — Return time-bucketed statistics (min, max, average, count). Ideal for summary views, bar charts, and very long time ranges.
Device-based vs location-based queries:
- Device-based APIs (e.g.,
observation/read-v2/device/{device-id}) return data from a single device. - Location-based APIs (e.g.,
location/{id}/observations/downsampled) return data from all devices installed at the location during the queried time range. Each returned point carries adevice-id, so a location with two devices reporting the same quantity yields points from both in one time-ordered series.
Choosing an endpoint
| What you want | Use |
|---|---|
| A line chart of one quantity at a location, over any time range | location/{id}/observations/downsampled |
| The same for one device you already know | observation/read-v2/device/{device-id}/downsampled |
| Bucketed statistics (min/max/avg per hour or day), or a compact series for very long ranges | location/{id}/aggregates/downsampled |
| Every individual observation — exports, custom processing, or client-side downsampling | location/{id}/observations-v2/read or observation/read-v2/device/{device-id} |
| A small, recent slice with no pagination | The stable Observation history API |
Prefer a downsampled or aggregated read whenever the data is destined for a chart. One year of 10-minute data is roughly 52 000 points per device, and a chart a few hundred pixels wide cannot display more than a few hundred of them.
Incomplete reads. The downsampled endpoints read the raw observations behind your window before reducing them. If a range is so large or so dense that they cannot read all of it, the response carries "truncated": true and the series — along with any min/max/mean — describes only the part that was read. The field is absent otherwise, so its presence always means something. In practice this needs a range of several years on a location with an unusually high reporting rate; if you see it, the range wants the aggregated endpoint instead.
Sizing samples. For the downsampled endpoints, set samples to the chart’s width in pixels — asking for more points than the chart has pixels only costs bandwidth. The aggregated endpoint treats samples differently; see Aggregated observations below before choosing a value there.
Observation history V2 (cursor-based) DRAFT
| Method | Location |
|---|---|
| GET | observation/read-v2/device/{device-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
| start | no | query | String (RFC3339) |
| end | yes | query | String (RFC3339, defaults to now) |
| quantity | yes | query | String |
| limit | yes | query | Integer (1–20000, default 10000) |
| cursor | yes | query | String (RFC3339, from previous response) |
Cursor-based paginated read for large time ranges without downsampling. Unlike the stable V1 API (limited to 5000 rows), this API supports unlimited data retrieval by paginating through results.
start is inclusive, cursor and end are exclusive, so consecutive windows whose end is the next start return each observation once.
Usage: Make the first request with start (and optionally end and quantity). If has-more is true in the response, make another request with the returned cursor value to get the next page. Repeat until has-more is false.
Example response
{
"observations": [
{
"dt": "2024-01-15T14:30:00Z",
"quantity": "temperature",
"value": 21.5,
"unit": "C"
},
{
"dt": "2024-01-15T14:30:00Z",
"quantity": "co2",
"value": 442,
"unit": "ppm"
}
],
"has-more": true,
"cursor": "2024-01-15T14:30:00Z"
}| Field | Type | Description |
|---|---|---|
observations |
array | Observation objects with ISO 8601 timestamps in dt field |
has-more |
boolean | true if more data is available |
cursor |
string | Timestamp to pass as cursor parameter for next page. null when no more data. |
Note: The V2 API uses dt (ISO 8601 string) for timestamps, unlike V1 which uses timestamp (epoch). The cursor field is the timestamp of the last observation in the page — pass it back to continue.
Location observation history V2 (cursor-based) DRAFT
| Method | Location |
|---|---|
| GET | location/{id}/observations-v2/read |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| start | no | query | String (RFC3339) |
| end | yes | query | String (RFC3339, defaults to now) |
| quantity | yes | query | String |
| limit | yes | query | Integer (1–20000, default 10000) |
| cursor | yes | query | String (RFC3339) |
Same as device-level V2 streaming, but returns observations from all devices at the given location. Each observation includes a device-id field to identify the source device. Same pagination pattern — use cursor to get next pages.
Downsampled observations DRAFT
| Method | Location |
|---|---|
| GET | location/{id}/observations/downsampled |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| quantity | no | query | String |
| start | no | query | String (RFC3339) |
| end | no | query | String (RFC3339) |
| samples | yes | query | Integer (default 500, capped at 2000) |
Returns observations downsampled to at most samples data points using the Largest-Triangle-Three-Buckets (LTTB) algorithm, which preserves the visual shape of the series while dramatically reducing the number of points. Values above 2000 are reduced to 2000. This is the recommended endpoint for drawing a location’s line chart. There is no limit on the length of the requested time range.
Alongside the points, the response carries min, max and mean for the window. These are computed over all observations in the range, before downsampling — so they remain accurate even at low samples values, and can be used to set a stable Y-axis or show a “peak was X” readout without a second request.
If no devices were installed at the location during the requested window — or none of them reported that quantity — all four fields come back empty (observations is [], the rest null). This is a normal result, not an error.
Example response
{
"min": {
"timestamp": "2024-01-15T04:20:00Z",
"value": 18.1,
"device-id": "device-001"
},
"max": {
"timestamp": "2024-01-15T15:10:00Z",
"value": 24.9,
"device-id": "device-002"
},
"mean": 21.4,
"observations": [
{
"timestamp": "2024-01-15T14:30:00Z",
"value": 21.5,
"device-id": "device-001"
},
{
"timestamp": "2024-01-15T14:40:00Z",
"value": 21.8,
"device-id": "device-001"
}
]
}| Field | Type | Description |
|---|---|---|
min |
object | The single lowest observation in the range, with its timestamp and source device (null if no data) |
max |
object | The single highest observation in the range (null if no data) |
mean |
number | Arithmetic mean of every observation in the range (null if no data) |
observations |
array | Downsampled data points, in ascending time order |
observations[].timestamp |
string | ISO 8601 timestamp |
observations[].value |
number | Numeric measurement value |
observations[].device-id |
string | Device that produced the point |
truncated |
boolean | Present and true only when the read could not cover the whole range; the points and the statistics then describe a subset of it |
Downsampled observations, points only DRAFT
| Method | Location |
|---|---|
| GET | location/{id}/observations/downsampled-v2 |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| quantity | no | query | String |
| start | no | query | String (RFC3339) |
| end | no | query | String (RFC3339) |
| samples | yes | query | Integer (default 500, capped at 2000) |
A narrower variant of the endpoint above: same LTTB downsampling and the same handling of long ranges, but the response contains only the observations list — no window min, max or mean.
As with the endpoint above, the response carries "truncated": true if the read could not cover the whole range.
Note: The -v2 suffix denotes this different response shape, not a newer generation of the API. Prefer location/{id}/observations/downsampled when you also want the window statistics.
Example response
{
"observations": [
{
"timestamp": "2024-01-15T14:30:00Z",
"value": 21.5,
"device-id": "device-001"
},
{
"timestamp": "2024-01-15T14:40:00Z",
"value": 21.8,
"device-id": "device-001"
}
]
}Device downsampled observations DRAFT
| Method | Location |
|---|---|
| GET | observation/read-v2/device/{device-id}/downsampled |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
| quantity | no | query | String |
| start | no | query | String (RFC3339) |
| end | no | query | String (RFC3339) |
| samples | yes | query | Integer (default 500, capped at 2000) |
Returns LTTB-downsampled observations for a single device and a single quantity over the requested time window. Useful when you already know the device and want a chart-ready time series without first looking up its current location. There is no limit on the length of the requested range. Values of samples above 2000 are silently reduced to 2000.
You receive data only for the devices and time windows you have access to, as in Observation history. If the caller has no access for the device in the requested window, an empty observations list is returned.
Example response
{
"observations": [
{ "timestamp": "2024-10-01T08:30:00Z", "value": 21.5 },
{ "timestamp": "2024-10-01T09:00:00Z", "value": 21.8 }
]
}| Field | Type | Description |
|---|---|---|
observations |
array | Downsampled data points, evenly distributed across the time range |
observations[].timestamp |
string | ISO 8601 timestamp |
observations[].value |
number | Numeric measurement value |
truncated |
boolean | Present and true only when the read could not cover the whole range |
Aggregated observations DRAFT
| Method | Location |
|---|---|
| GET | location/{id}/aggregates/downsampled |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| quantity | no | query | String |
| start | no | query | String (RFC3339) |
| end | no | query | String (RFC3339) |
| samples | yes | query | Integer (default 500) |
Returns time-bucketed aggregate statistics (min, max, average, count), optionally downsampled to samples points. Buckets are aligned to whole UTC hours or whole UTC days — note that a day bucket therefore starts at 00:00 UTC, not at local midnight.
samples has two effects here, which is worth understanding before choosing a value:
- It selects the bucket period. If the requested range is longer in hours than
samples,daybuckets are returned; otherwisehourbuckets are. Unlike the downsampled endpoints,samplesis not capped here — it has to be free to exceed 2000 so that long ranges can still ask forhourbuckets. - It is also the LTTB target applied to the resulting buckets.
Passing the chart’s pixel width — the right choice for the downsampled endpoints — therefore has a side effect here: a narrow chart flips to day buckets sooner than a wide one. Instead, decide which period you want and set samples at or above the number of buckets that period produces, so no thinning takes place:
- One year of
daybuckets: anysamplesfrom 365 to 8759 → 365 buckets returned - 32 days of
hourbuckets:samplesof 768 or more → 768 buckets returned
Then thin client-side if you want fewer points. This matters if you intend to draw a min–max band: LTTB selects buckets by their average, so letting it thin the series drops the buckets holding the extremes and the band will understate the true peaks.
Aggregates are produced by a background analysis process and are stored per location, so they are already combined across all devices at that location.
Example response
{
"min": {
"timestamp": "2024-01-15T03:00:00Z",
"avg": 19.5,
"min": 18.0,
"max": 20.5,
"count": 6,
"period": "hour"
},
"max": {
"timestamp": "2024-01-15T14:00:00Z",
"avg": 23.5,
"min": 22.0,
"max": 25.0,
"count": 8,
"period": "hour"
},
"avg": 21.8,
"aggregates": [
{
"timestamp": "2024-01-15T08:00:00Z",
"avg": 21.2,
"min": 20.0,
"max": 22.5,
"count": 6,
"period": "hour",
"quantity": "temperature"
}
],
"analysis-in-progress": false
}| Field | Type | Description |
|---|---|---|
min |
object | The whole aggregate bucket containing the lowest observed value in the range — not a bare number (null if no data) |
max |
object | The whole aggregate bucket containing the highest observed value in the range (null if no data) |
avg |
number | Mean of the observations across the whole range: each bucket’s avg weighted by its count (null if no data) |
aggregates |
array | Time-bucketed aggregates, downsampled to samples points |
analysis-in-progress |
boolean | true if background analysis is still running. When true, aggregates is empty and min/max/avg are null — this means “results are being recalculated”, not “no data exists”. Retry shortly. |
Note: The range is filtered as start ≤ bucket < end, i.e. the end bound is exclusive. A window ending exactly on a bucket boundary — midnight, for day buckets — will not include the bucket starting at that instant. The observation endpoints, by contrast, include their end instant.
Export observations as CSV DRAFT
| Method | Location |
|---|---|
| GET | location/{id}/csv |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| start | no | query | String (RFC3339) |
| end | yes | query | String (RFC3339) |
| quantities | yes | query | String (comma-separated identifiers) |
| compact | yes | query | Boolean (default false) |
| utc | yes | query | Boolean (default true) |
| local | yes | query | Boolean (default false) |
| tz | yes | query | String (IANA timezone, e.g. Europe/Helsinki) |
| separator | yes | query | String (single char, default ,) |
| decimal | yes | query | String (single char, default .) |
| bom | yes | query | Boolean (default false) |
| preview | yes | query | Integer (1–1000) |
Downloads observations at the given location as a CSV file, over the [start, end] window (end defaults to now). The response is streamed, so arbitrarily large time ranges can be exported. With no optional parameters the output is unchanged from before: one column per quantity the location’s devices have ever reported (sorted), a single UTC dt column, and device_id and name columns, comma-separated.
Columns. Precedence: quantities (an explicit comma-separated list) wins and fully determines the columns and order — compact is ignored when it is given, and a listed quantity with no data in the window still appears as an empty column. Otherwise compact=true includes only quantities that have data within [start, end]. Otherwise (default) every quantity the devices have ever reported is included.
Timestamps. utc and local are independent columns. The default (utc=true, local=false) emits a single dt column in UTC. Enabling local adds a column in the tz timezone; tz is required when local=true. With both enabled the columns are named dt_utc and dt_local.
Format. separator sets the field delimiter and decimal the decimal mark for numeric values; they must differ. bom=true prepends a UTF-8 byte-order mark so Excel detects UTF-8 (a Finnish/Swedish Excel file is separator=;, decimal=,, bom=true).
Preview. preview=N returns only the first N output rows (used by the export dialog’s preview pane).
Returns 400 if separator equals decimal, if local=true without a valid tz, or if quantities lists more than 200 entries. Returns 503 if too many CSV exports are already in progress (retry shortly).
Quantities & Measurements DRAFT
These APIs return quantity metadata and latest measurement values for locations. Unlike the device-based APIs in the stable section, these use path-based queries — you provide a location path (e.g., campus.office) and get data for all locations under that path.
Key concepts:
- Path parameter: A dot-separated location path (e.g.,
campus.office). Returns data for all leaf nodes (rooms) under that path. - Exclusive quantities: A per-location whitelist that controls which quantities are shown in the UI. When
respect-exclusiveistrue(default), only whitelisted quantities are returned. - Hidden quantities: Quantities marked as hidden in the system configuration. Use
include-hidden=trueto include them. - System quantities: Device health metrics (
batt,rsrp,snr) that are not regular measurements. Useinclude-system=trueto include them.
Get quantity metadata DRAFT
| Method | Location |
|---|---|
| GET | quantities-v2 |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| path | no | query | String (location path) |
| quantities | yes | query | String (comma-separated identifiers) |
| include-hidden | yes | query | Boolean (default false) |
| include-exclusive | yes | query | Boolean (default true) |
| start | yes | query | ISO 8601 timestamp |
| end | yes | query | ISO 8601 timestamp |
Returns metadata about all known quantities and per-location configuration for all locations under the given path. This includes threshold configurations, custom display names, and visibility settings.
Time window (start + end): When both are provided, each location entry includes an observed-quantities field listing every quantity identifier that was produced by any device whose installation at that location overlaps the [start, end] window. Use this when you need to know which quantities are worth querying historically for a location in a given time range — it surfaces data from devices that are no longer currently installed. Both parameters must be given together; if either is omitted, no observed-quantities is attached.
Example response
{
"quantities": {
"temperature": {
"unit": "C",
"shown": true,
"chart-type": "line",
"delay-minutes": 30,
"hysteresis-minutes": 15,
"default-thresholds": {
"S1+": [[15, 0], [20, 2], [20.5, 5], [25, 5], [27, 2], [32, 0]],
"S2+": [[15, 0], [20, 2], [20.5, 5], [26, 5], [27, 2], [32, 0]],
"S3+": [[15, 0], [20, 2], [20.5, 5], [26, 5], [27, 2], [32, 0]]
}
},
"co2": {
"unit": "ppm",
"shown": true,
"chart-type": "line",
"default-thresholds": {
"S1+": [[0, 5], [750, 5], [950, 2], [2000, 0]],
"S2+": [[0, 5], [950, 5], [1200, 2], [2000, 0]],
"S3+": [[0, 5], [1200, 5], [1550, 2], [2000, 0]]
}
},
"batt": {
"unit": "%",
"shown": false,
"default-thresholds": {
"default": [[0, 0], [11, 2], [30, 5], [100, 5]]
}
}
},
"locations": {
"15": {
"location-id": 15,
"path": "campus.office.floor_1.room_101",
"name": "Meeting Room",
"tag": "room",
"location-thresholds": {
"temperature": {
"aqi-curve": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]]
}
},
"exclusive-quantities": ["temperature", "humidity", "co2"],
"alternative-names": {
"temperature": "Supply Air Temp"
},
"observed-quantities": ["temperature", "humidity", "co2", "batt"]
}
}
}The observed-quantities field appears only when start and end were both provided.
Response fields:
The quantities map contains global quantity definitions keyed by identifier. The locations map contains per-location data keyed by location ID.
Quantity fields (entries under quantities.<identifier>):
| Field | Type | Description |
|---|---|---|
unit |
string | Display unit |
shown |
boolean | Whether the quantity is shown by default in the UI |
chart-type |
string | Recommended chart rendering (line, bar, …) |
delay-minutes |
integer | Default alarm delay (see Alarms section). Optional. |
hysteresis-minutes |
integer | Default alarm hysteresis. Optional. |
default-thresholds |
object | System default AQI curves for this quantity, keyed by threshold tier. Indoor-air quantities use S1+/S2+/S3+; quantities outside the Finnish standard use a single default key. Each value is an AQI curve (see the Analysis section for format and tier semantics). Absent for quantities with no configured thresholds (e.g., pm10*). May be null for quantities where thresholds were explicitly cleared (e.g., lafmax). |
Location fields (entries under locations.<id>):
| Field | Type | Description |
|---|---|---|
location-thresholds |
object | User-defined AQI curves per quantity, overriding default-thresholds when present. Keyed by quantity identifier; each value is {"aqi-curve": [...]}. Only present if configured. |
exclusive-quantities |
string[] | Quantity whitelist for this location. Only present if configured. |
alternative-names |
object | Custom display names per quantity. Only present if configured. Stored on the location node as quantity-names (see Admin APIs → Data (Node Metadata) → quantity-names); renamed to alternative-names in this response. |
observed-quantities |
string[] | Quantities produced by any device whose installation at this location overlaps [start, end]. Only present when both start and end query params are given. Empty array means no installation overlapped the window. Sourced from the latest-observation index, so a quantity is listed if the device has ever reported it (historical availability, not “reported within the window”). |
Get latest measurements DRAFT
| Method | Location |
|---|---|
| GET | last-quantities |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| path | no | query | String (location path) |
| quantities | yes | query | String (comma-separated identifiers) |
| include-device-info | yes | query | Boolean (default false) |
| respect-exclusive | yes | query | Boolean (default true) |
| include-system | yes | query | Boolean (default true) |
Returns the most recent measurement value for each quantity at each location under the given path. This is the primary API for displaying current values across a building or site.
Example response
{
"locations": {
"15": {
"location-id": 15,
"path": "campus.office.floor_1.room_101",
"name": "Meeting Room",
"values": {
"temperature": {
"value": 21.4,
"datetime": "2024-01-15T14:30:00Z",
"unit": "C",
"device-id": "device-001",
"device-type": "L103EWC0301",
"product-name": "Loop One"
},
"co2": {
"value": 442,
"datetime": "2024-01-15T14:30:00Z",
"unit": "ppm"
}
},
"device": {
"device-id": "device-001",
"device-type": "L103EWC0301",
"product-name": "Loop One",
"notable-flags": [
{ "key": "notable.dense_interval", "tone": "warn", "params": { "interval": 120 } }
]
}
}
}
}Note: Device metadata is returned in two places, both only when include-device-info=true:
- The location-level
deviceobject reflects the currently installed device at that location. It is sourced from the installation record, so it is present whenever a device is installed — independent of whether that device has reported any measurements. Omitted when no device is currently installed. - The per-value
device-id,device-type, andproduct-namefields identify the device that produced each individual measurement. - The
deviceobject’s optionalnotable-flagsarray highlights attention-worthy configuration settings of the installed device. It is present for every caller except a share link, and only when the device has at least one flag. See Notable device settings.
An empty values map does not imply that no device is installed: a device whose most recent observation predates its current installation produces no in-scope values yet still appears under device. To detect whether a location has a device, check the device object rather than values.
product-name is the human-readable product name (e.g. "Loop One", "Loop Delta") resolved server-side from the first two characters of device-type. It is null when the device’s type prefix is not in the product catalog (e.g. legacy or third-party devices).
Recipe: Coloring a current measurement value DRAFT
To color a last-quantities value the same way the Loopshore web UI does, combine the per-location override, the global defaults, and the user’s selected strictness tier. Fetch quantities-v2 and last-quantities with the same path. For each value, pick a curve in this priority order:
- Per-location override. If
quantities-v2.locations[<id>].location-thresholds[<quantity>].aqi-curveexists AND your user has “use location thresholds” enabled, use that single curve. - Tier-specific default. Otherwise, read
quantities-v2.quantities[<quantity>].default-thresholds[<tier>], where<tier>is the user’s selected strictness ("S1+","S2+", or"S3+"). - Non-tiered default. If the quantity has no tiered curves (e.g.,
batt,rsrp), fall back todefault-thresholds.default. - No curve available. If none of the above exist (e.g.,
pm10*, orlafmaxwhere thresholds are unset), do not color the value — render it in a neutral style.
Once you have a curve, classify the measurement value against it using the classify algorithm under Analysis & Air Quality below, and map the result to the status colors in that section.
This is the same classification the web UI, the backend analysis engine, and the alarm system all use — it is not a continuous 0–5 score compared against a fixed cutoff like 3.5. (An earlier version of this recipe said to interpolate a continuous score and threshold it at 3.5/2.0; that was wrong and disagreed with every real implementation, including this API’s own analysis engine — do not use it.)
Note. Calling the classifier with an empty thresholds object silently returns “good” for every value, because the absence of any breakpoint causes all values to fall into the default classification. Always check that you found a non-null curve before classifying.
Analysis & Air Quality DRAFT
The analysis system evaluates air quality by comparing measurement data against threshold curves. It produces good/warn/alarm classifications and percentage breakdowns for each quantity at each location.
AQI curve format: An array of [measurement_value, quality_score] coordinate pairs that define a piecewise-linear mapping function. quality_score runs 0 (worst) to 5 (best) and exists only to define the curve’s shape — no endpoint returns it as a standalone number. Values between points are linearly interpolated; values outside the outermost points are clamped to the nearest endpoint score.
Example (temperature, S1+): [[15.0, 0.0], [20.0, 2.0], [20.5, 5.0], [25.0, 5.0], [27.0, 2.0], [32.0, 0.0]]
Reading: 15 °C → score 0 (bad), 20 °C → score 2 (alarm/warn boundary), 20.5–25 °C → score 5 (optimal plateau), 27 °C → score 2, 32 °C → score 0.
Classifying a value (good / warn / alarm): A curve author sets value breakpoints — alarm-low ≤ goal-low ≤ goal-high ≤ alarm-high (one-sided curves omit the low or high pair) — and those are, by construction, exactly the measurement_values where the curve’s score crosses 2 (alarm/warn boundary) and 5 (warn/good boundary). Classification recovers those breakpoints from the curve and buckets the value against them directly. It does not interpolate a continuous score and compare it to a fixed number like 3.5 — no such cutoff exists anywhere in the actual implementation:
function classify(curve, value):
type = curveShape(curve) // 'raising' | 'declining' | 'hump' | 'pit'
warnBounds = valuesWhereScoreEquals(curve, 2) // 1 or 2 values, via reverse interpolation
goodBounds = valuesWhereScoreEquals(curve, 5) // 1 or 2 values, via reverse interpolation
switch type:
case 'raising': // bigger value = better
if value < warnBounds[0]: return ALARM
if value < goodBounds[0]: return WARN
return GOOD
case 'declining': // bigger value = worse
if value > last(warnBounds): return ALARM
if value > last(goodBounds): return WARN
return GOOD
case 'hump': // the common two-sided case: alarm-low/goal-low/goal-high/alarm-high
if value < warnBounds[0] or value > warnBounds[1]: return ALARM
if value < goodBounds[0] or value > goodBounds[1]: return WARN
return GOOD
case 'pit': // inverse of hump — good is outside, alarm is in the middle
if value < goodBounds[0] or value > goodBounds[1]: return GOOD
if value < warnBounds[0] or value > warnBounds[1]: return WARN
return ALARM
curveShape looks at whether the score rises, falls, or reverses direction once as measurement_value increases across the curve’s points. hump is what a normal two-sided threshold (alarm-low/goal-low/goal-high/alarm-high) produces. This is the same algorithm the web UI and the backend analysis engine both use — for a curve built from typed-in breakpoints, it reproduces those exact breakpoints, because they are precisely where the curve was defined to cross score 2 and score 5.
Status colors:
| Category | Color |
|---|---|
| Good | Green (#3AE287) |
| Warning | Yellow (#FFB800) |
| Alarm | Orange (#FA5B02) |
Threshold modes:
For each quantity, the system can evaluate measurements against one or more AQI curves. Each curve is classified independently using the classify algorithm above, into the status colors in the table above.
- Finnish Classification of Indoor Environment (default). For indoor-air quantities (temperature, CO₂, humidity, PM2.5, TVOC, noise), the system defines three parallel strictness tiers:
S1+(individual / strictest),S2+(good),S3+(satisfactory / most lenient). These are three separate curves, not three bands within one curve. The analysis response contains a separate good/warn/alarm breakdown per tier underanalyses[].thresholds.S1+,.S2+, and.S3+. Your client picks which tier to present. - Particle counts.
pn0p5andpn1p0have the same curve underS1+,S2+andS3+, and nodefault. - Non-tiered defaults. Quantities outside the Finnish standard (e.g., battery, signal strength) use a single curve under the
defaultkey. - User-defined thresholds (identifier
lt, “location threshold”). Custom AQI curves configured per location per quantity, overriding the default curves when present. Passthreshold=useron analysis requests to compute against these. Inquantities-v2, they appear underlocations.<id>.location-thresholds.<quantity>.aqi-curve.
Get analysis results DRAFT
| Method | Location |
|---|---|
| GET | analysis-v2/location |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| path | no | query | String (location path) |
| start | no | query | String (RFC3339) |
| end | yes | query | String (RFC3339) |
| tag | yes | query | String (e.g. room) |
| quantity | yes | query | String (filter for specific quantity) |
| threshold | yes | query | String (user for custom thresholds) |
Returns aggregated analysis results for locations under the given path. By default, returns the immediate children of the path. Use tag to filter to a specific level (e.g., tag=room returns all rooms under the path). Archived locations are excluded. A share link gets only the shared location: with tag, when it has that tag; without tag, an empty list.
Example response
[
{
"path": "campus.office.floor_1.room_101",
"location": 15,
"name": "Meeting Room",
"analysis_in_progress": false,
"analyses": [
{
"quantity": "temperature",
"numeric": {
"avg": {"value": 22.1},
"min": {"value": 19.5, "dt": "2024-01-15T03:00:00Z"},
"max": {"value": 24.8, "dt": "2024-01-15T14:30:00Z"}
},
"thresholds": {
"S1+": {
"total": {"percents": 100, "count": 144},
"good": {"percents": 85, "count": 122},
"warn": {"percents": 10, "count": 15},
"alarm": {"percents": 5, "count": 7}
}
}
},
{
"quantity": "co2",
"numeric": {
"avg": {"value": 580},
"min": {"value": 410, "dt": "2024-01-15T06:00:00Z"},
"max": {"value": 1150, "dt": "2024-01-15T13:00:00Z"}
},
"thresholds": {
"S1+": {
"total": {"percents": 100, "count": 144},
"good": {"percents": 72, "count": 104},
"warn": {"percents": 20, "count": 29},
"alarm": {"percents": 8, "count": 11}
}
}
}
]
}
]Response fields:
| Field | Type | Description |
|---|---|---|
analysis_in_progress |
boolean | true if analysis is currently running for this location |
analyses[].numeric.avg |
object | Average value over the period |
analyses[].numeric.min |
object | Minimum value with timestamp |
analyses[].numeric.max |
object | Maximum value with timestamp |
analyses[].thresholds |
object | Quality breakdown per threshold standard. Keys are threshold identifiers (S1+, S2+, S3+ for Finnish standards, lt for user-defined). |
thresholds.*.good |
object | Percentage and count of time in “good” quality range |
thresholds.*.warn |
object | Percentage and count of time in “warning” range |
thresholds.*.alarm |
object | Percentage and count of time in “alarm” range |
thresholds.*.total |
object | Total data points analyzed |
Alarms DRAFT
The alarm system monitors measurement data and sends notifications when values cross configured thresholds. Each user configures their own alarms — alarms are per-user, per-location, per-quantity.
Alarm configuration types:
| Type | Description | Quantity |
|---|---|---|
location_threshold |
Triggers when measurement crosses a threshold curve | Required |
location_no_data |
Triggers when no data received for the configured delay | Must be null |
device_problem |
Triggers when a device reports a hardware, software, environmental, or communication problem. These records are auto-generated from device telemetry — they are not user-created. Currently not creatable via the POST endpoint below; existing device_problem configs are provisioned by the platform operator. When one exists, its events and state are still returned by the GET endpoints. Note: battery level monitoring is not a device problem — use location_threshold with quantity: "batt" instead (see battery example below). |
Optional |
Alarm lifecycle:
- idle — Normal state, no alarm condition
- pending — Threshold breached, waiting for
delayminutes before activating - activated — Delay expired, notification sent. Alarm stays activated until the condition clears and
hysteresisminutes pass - idle — Condition cleared, alarm resets
Quantity scope: location_threshold alarms work with any quantity the system tracks — both environmental measurements (temperature, humidity, co2, …) and device-health metrics (batt, rsrp, snr). For example, to alert when battery drops below a level, create a location_threshold alarm with quantity: "batt" and an appropriate threshold curve. See the quantities table for the full list.
Configuration parameters:
- delay (minutes): How long the threshold must be continuously breached before the alarm activates. Prevents false alarms from momentary spikes.
- hysteresis (minutes): After an alarm clears, how long to wait before the alarm can re-trigger. Prevents rapid on/off cycling.
- medium: Notification channels.
[1]= email,[2]= SMS,[1, 2]= both,[]= no notifications. - aqi-curve: The location’s threshold curve for the quantity. The curve is an array of
[measurement_value, quality_score]pairs defining a piecewise-linear mapping — see AQI curve format in the Analysis & Air Quality section for full details (interpolation rules, clamping at endpoints, quality score range 0–5). A valid curve has at least two distinct points and crosses both score 2 (alarm limit) and score 5 (goal limit); see Building a curve from limits below.
The curve belongs to the location, not to one user’s alarm: there is one per location and quantity, shared by every user’s alarm on that quantity there and by the user-defined thresholds (lt) in analysis. Set or delete it with Set location threshold and Delete location threshold. When it changes, every alarm on that quantity at the location gets a threshold_update or threshold_deleted event and returns to idle, so it is evaluated again against the new curve.
Without a curve, threshold alarms don’t fire. The UI and analysis then rate measurements with the quantity’s default thresholds, but alarms have no default to fall back to: a location_threshold alarm on a measured quantity stays silent until someone sets the location’s curve.
Breach condition:
The alarm is considered breached when the aqi-curve score for the current measurement drops below 2.0. This is the same alarm/warn boundary the classify algorithm in Analysis & Air Quality uses (< 2.0 = Alarm). When designing a custom curve, make sure the region you want to alarm on maps to a score below 2, and the safe region maps to a score ≥ 2. See the upper-threshold example below.
Get alarm events DRAFT
| Method | Location |
|---|---|
| GET | alarm/events |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| start | no | query | String (RFC3339) |
| end | no | query | String (RFC3339) |
Returns alarm events for the current user within the given time range. Maximum 5000 events, ordered by timestamp (newest first).
Only events of alarms at locations you can view now are returned. Alarms at archived locations, or at locations you no longer have access to, are left out; the alarm configurations are kept, and their events are returned again if access is restored.
Example response
[
{
"id": 1,
"config-id": 42,
"event-type": "activated",
"timestamp": "2024-01-15T14:30:00Z",
"reference-id": null,
"event-text": null,
"user-id": 1,
"location-id": 15,
"location-path": "campus.office.floor_1.room_101",
"location-name": "Meeting Room",
"quantity": "temperature",
"config-type": "location_threshold"
}
]| Field | Type | Description |
|---|---|---|
id |
integer | Event ID |
config-id |
integer | Alarm config this event belongs to |
event-type |
string | See event types table below |
timestamp |
datetime | Event time, truncated to seconds precision |
reference-id |
integer | References another alarm event by id (self-referential foreign key). For example, a notification event may reference the activation that triggered it. null when not applicable. |
event-text |
string | Optional free-form text. Populated for some event types — e.g. delay_update and hysteresis_update carry "value=<n>". null otherwise. |
user-id |
integer | Owner of the alarm config |
location-id |
integer | Location the alarm is attached to |
location-path |
string | Dotted location path |
location-name |
string | Display name of the location |
quantity |
string | Measurement quantity. null for location_no_data and device_problem configs. |
config-type |
string | Alarm config type (see table above) |
Event types:
| Event type | Description |
|---|---|
activated |
Alarm triggered after delay expired |
pending |
Threshold breached, waiting for delay |
deactivated |
Alarm cleared |
email_notification |
Email notification sent |
sms_notification |
SMS notification sent |
threshold_update |
The location’s threshold curve was changed; every alarm on that quantity there gets one |
threshold_deleted |
The location’s threshold curve was removed; every alarm on that quantity there gets one |
delay_update |
Delay setting was changed |
hysteresis_update |
Hysteresis setting was changed |
alarm_enabled |
Alarm was enabled |
alarm_disabled |
Alarm was disabled |
email_enabled |
Email notifications enabled |
email_disabled |
Email notifications disabled |
sms_enabled |
SMS notifications enabled |
sms_disabled |
SMS notifications disabled |
device_problem_activated |
Device problem alarm triggered |
device_problem_resolved |
Device problem resolved |
acknowledgement |
Legacy value — present in the database enum but not produced by the current backend. May appear in historical data. |
Get alarm state DRAFT
| Method | Location |
|---|---|
| GET | alarm/state |
Returns the current state of the alarms configured by the current user at locations you can view now. As with alarm events, alarms at archived locations or at locations you no longer have access to are left out.
Example response
[
{
"config-id": 42,
"current-state": "idle",
"enabled": true,
"last-breached": null,
"last-activated": null,
"user-id": 1,
"location-id": 15,
"location-path": "campus.office.floor_1.room_101",
"location-name": "Meeting Room",
"quantity": "temperature",
"config-type": "location_threshold"
}
]| Field | Type | Description |
|---|---|---|
current-state |
string | Current alarm state: idle, pending, activated, or deactivated. Newly-created configs start at idle and transition through pending → activated → idle (or deactivated) as the alarm runs. |
enabled |
boolean | Whether the alarm is enabled |
last-breached |
datetime | When the threshold was last breached (null if never) |
last-activated |
datetime | When the alarm last triggered a notification (null if never) |
Get alarm configuration DRAFT
| Method | Location |
|---|---|
| GET | alarm/location/{id}/alarm-properties-v2 |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
| config-type | yes | query | String (default location_threshold) |
| quantity | yes | query | String |
Get the current user’s alarm configuration for a specific location. Returns 404 if no alarm is configured.
Example response
{
"delay-minutes": 30,
"hysteresis-minutes": 15,
"enabled": true,
"medium": [1, 2],
"aqi-curve": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]],
"config-type": "location_threshold",
"default-thresholds": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]]
}| Field | Type | Description |
|---|---|---|
delay-minutes |
integer | Minutes to wait before activating |
hysteresis-minutes |
integer | Minutes to wait before re-triggering |
enabled |
boolean | Whether the alarm is enabled |
medium |
int[] | Notification channels: [1]=email, [2]=SMS, [1,2]=both |
aqi-curve |
array | The location’s threshold curve, or null when none is set. Without a curve the alarm doesn’t fire. |
config-type |
string | Alarm type (see table above) |
default-thresholds |
array | System default thresholds for this quantity (read-only) |
Set alarm configuration DRAFT
| Method | Location |
|---|---|
| POST | alarm/location/{id}/alarm-properties-v2 |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
| Body | Optional | Datatype |
|---|---|---|
| delay | no | Integer (minutes, >= 0) |
| hysteresis | no | Integer (minutes, >= 0) |
| medium | no | Integer[] (notification channels) |
| config-type | yes | String: location_threshold (default) or location_no_data |
| quantity | yes | String |
| aqi-curve | yes | For batt, rsrp and snr, the alarm’s curve; null removes it. For a measured quantity, leave it out or send null: the location’s curve stays as it is, and you set it with Set location threshold. A curve sent for a measured quantity returns 400. |
| enabled | yes | Boolean (default true) |
Create or update an alarm configuration. Only location_threshold and location_no_data configs can be created through this endpoint; passing device_problem returns 400 "Invalid config type".
Required role: viewer or a wider tier at the location, or alarm-subscriber on a guest-viewer grant in another organization. Quantity alarms need more; see below. Not open to share links.
A threshold alarm on a measured quantity (location_threshold on any quantity except the system quantities batt, rsrp and snr) needs quantity alarm subscriptions at the location: org-admin, building-admin, or a tier with the alarm-subscriber add-on. Without it the request returns 403, except when it only switches off your own existing alarm (enabled false). The save never changes or deletes the location’s curve. Alarms about the device itself need only a tier from viewer up at the location, or alarm-subscriber on a guest-viewer grant in another organization: no-data alarms (location_no_data) and thresholds on batt, rsrp or snr.
Example request
{
"delay": 30,
"hysteresis": 15,
"medium": [1, 2],
"config-type": "location_threshold",
"quantity": "temperature",
"enabled": true
}Example response
{
"id": 42,
"delay-minutes": 30,
"hysteresis-minutes": 15,
"enabled": true,
"medium": [1, 2],
"quantity": "temperature",
"config-type": "location_threshold",
"aqi-curve": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]]
}| Field | Type | Description |
|---|---|---|
id |
integer | Alarm config ID (use this to correlate with events and state) |
delay-minutes |
integer | Configured delay |
hysteresis-minutes |
integer | Configured hysteresis |
enabled |
boolean | Whether the alarm is enabled |
medium |
int[] | Notification channels |
quantity |
string | Measurement quantity. null for location_no_data configs. |
config-type |
string | Alarm config type |
aqi-curve |
array | The location’s threshold curve, or null when none is set. null for location_no_data configs. |
Note: the POST response does not include default-thresholds. Use the GET endpoint if you need to read the system default thresholds for a quantity.
Building a curve from limits
A threshold is usually described with up to four limits: alarm-low < goal-low ≤ goal-high < alarm-high. Each limit becomes one curve point, in ascending order of value:
| Limit | Score |
|---|---|
| alarm-low | 2.0 |
| goal-low | 5.0 |
| goal-high | 5.0 |
| alarm-high | 2.0 |
Beyond each alarm limit, add one more point with score 0.0, so that values past the limit score below 2 and breach the alarm. Values between an alarm limit and its goal limit are classified as warn, and values between the goal limits as good.
Each alarm limit needs the goal limit on the same side: alarm-low with goal-low, alarm-high with goal-high. A one-sided threshold leaves out the pair on the other side.
Two-sided, temperature: alarm below 19 °C or above 26 °C, good from 20.5 °C to 23.5 °C:
"aqi-curve": [[18.0, 0.0], [19.0, 2.0], [20.5, 5.0], [23.5, 5.0], [26.0, 2.0], [27.0, 0.0]]Upper side only, CO₂: alarm above 1200 ppm, good up to 750 ppm:
"aqi-curve": [[750.0, 5.0], [1200.0, 2.0], [1300.0, 0.0]]Lower side only, battery: alarm below 20 %, good from 30 %:
"aqi-curve": [[10.0, 0.0], [20.0, 2.0], [30.0, 5.0]]Example: upper-threshold alarm
Scenario: send an email when the temperature at a location stays above 23 °C for 45 minutes.
The default Finnish indoor thresholds are bell-shaped (too cold and too warm both count as breaches), so to alarm only on the upper side you supply a custom curve that drops below score 2 as soon as the value exceeds 23 °C:
{
"delay": 45,
"hysteresis": 0,
"medium": [1],
"config-type": "location_threshold",
"quantity": "temperature",
"aqi-curve": [[22.99, 5.0], [23.0, 2.0], [23.01, 0.0]]
}How the curve works:
- At 22.99 °C and below, the score clamps to 5.0 → safe.
- At exactly 23.0 °C, the score is 2.0 → still safe (breach requires below 2).
- At 23.01 °C and above, the score clamps to 0.0 → breached.
- With
delay: 45, the alarm must stay breached for 45 continuous minutes before activating and sending the email. - With
hysteresis: 0, the alarm can re-trigger as soon as the condition re-breaches after clearing.
The same pattern inverts for lower-threshold alarms (e.g. “below 18 °C”): put the low-score region on the left and the high-score region on the right.
Example: battery-low alarm
Scenario: send an email when battery at a location stays below 10 % for 60 minutes.
{
"delay": 60,
"hysteresis": 0,
"medium": [1],
"config-type": "location_threshold",
"quantity": "batt",
"aqi-curve": [[9.99, 0.0], [10.0, 2.0], [10.01, 5.0]]
}How the curve works:
- At 9.99 % and below, the score clamps to 0.0 → breached.
- At exactly 10.0 %, the score is 2.0 → still safe (breach requires below 2).
- At 10.01 % and above, the score clamps to 5.0 → safe.
Caution — device-specific timing: Different device types have different battery lifetimes and measurement intervals. While the API allows free configuration of
battthresholds andlocation_no_datadelays, it is recommended to use the system default values unless you have a specific reason to override. For example, setting a 30-minutelocation_no_datadelay on a device that only reports once per hour will cause repeated false alarms. The system defaults are tuned per device type by the platform operator.
Validation rules:
location_thresholdrequiresquantityto be setlocation_no_datarequiresquantityto be nulldevice_problemcannot be set via this endpoint- For a measured quantity,
aqi-curvenull or left out keeps the location’s curve; only Delete location threshold removes it. Any otheraqi-curve, also the curve already set, returns400 {"error": "Bad Request", "message": "A quantity alarm's threshold is set through the location's threshold endpoint"}, and nothing is saved. Forbatt,rsrpandsnr, null removes the curve - For
batt,rsrpandsnr,aqi-curvemust have at least two distinct points and cross both score 2 and score 5. Any other curve, such as an empty array, a single point, or a curve whose scores never reach 2 or 5, returns400 {"error": "Bad Request", "message": "Invalid aqi-curve"}, and nothing is saved
Get location threshold DRAFT
| Method | Location |
|---|---|
| GET | alarm/location/{id}/threshold |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
| quantity | no | query | String |
The location’s threshold curve for a quantity, shared by every alarm on it there.
Required role: guest-viewer or a wider tier at the location, or a share link.
Example response
{
"quantity": "co2",
"aqi-curve": [[750.0, 5.0], [1200.0, 2.0], [1300.0, 0.0]],
"default-thresholds": [[0.0, 5.0], [1050.0, 5.0], [1300.0, 0.0]],
"subscribers": 3
}| Field | Type | Description |
|---|---|---|
quantity |
string | The quantity asked for |
aqi-curve |
array | The location’s curve, or null when none is set. Without a curve, threshold alarms on it don’t fire. |
default-thresholds |
array | The quantity’s default thresholds, which the UI and analysis use without a curve |
subscribers |
integer | Enabled alarms on this quantity at the location. Only for those who may edit thresholds there; deleting the curve stops these alarms. |
Set location threshold DRAFT
| Method | Location |
|---|---|
| PUT | alarm/location/{id}/threshold |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
| Body | Optional | Datatype |
|---|---|---|
| quantity | no | String, a measured quantity |
| aqi-curve | no | Array of [measurement_value, quality_score] pairs |
Sets the location’s threshold curve for a quantity. When the curve changes, every alarm on that quantity at the location gets a threshold_update event and returns to idle, and the location’s analysis is recalculated. Sending the curve already set changes nothing.
Required role: alarm threshold editing at the location: org-admin, building-admin, or the threshold-editor add-on. Not open to share links.
The response is the same as Get location threshold.
Example request
{
"quantity": "co2",
"aqi-curve": [[750.0, 5.0], [1200.0, 2.0], [1300.0, 0.0]]
}Rules: - The curve follows the rules in Building a curve from limits above; anything else returns 400 {"error": "Bad Request", "message": "Invalid aqi-curve"}, and nothing is saved - batt, rsrp and snr return 400: their curve is saved with the alarm itself (Set alarm configuration)
Delete location threshold DRAFT
| Method | Location |
|---|---|
| DELETE | alarm/location/{id}/threshold |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
| quantity | no | query | String, a measured quantity |
Removes the location’s threshold curve for a quantity. Every alarm on that quantity at the location gets a threshold_deleted event, returns to idle, and doesn’t fire until a curve is set again; check subscribers in Get location threshold first. Deleting a curve that isn’t set changes nothing.
Required role: as for Set location threshold. batt, rsrp and snr return 400.
The response is the same as Get location threshold, with aqi-curve null.
Weather Data DRAFT
Weather data allows overlaying outdoor conditions (temperature, humidity, etc.) on indoor measurement graphs. Each location can be linked to a nearby weather station.
Search weather stations DRAFT
| Method | Location |
|---|---|
| GET | weather-station/search |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| q | no | query | String (search term) |
| limit | yes | query | Integer |
Search for weather stations by name.
Example response
{
"stations": [
{
"id": 1,
"name": "Helsinki-Vantaa lentoasema",
"lat": 60.3172,
"lon": 25.2458,
"provider": "fmi",
"external-id": "100953"
}
]
}Get weather observations DRAFT
| Method | Location |
|---|---|
| GET | weather-station/{id}/observations |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (station ID) |
| start | no | query | String (RFC3339) |
| end | no | query | String (RFC3339) |
Get weather observation data from a station within the given time range.
Set weather station for location DRAFT
| Method | Location |
|---|---|
| PUT | location/{id}/weather/config |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
| Body | Optional | Datatype |
|---|---|---|
| station-id | no | Integer |
Link a weather station to a location.
Required role: viewer or a wider tier at the location, or a read-write share link
Example request
{
"station-id": 1
}Remove weather station from location DRAFT
| Method | Location |
|---|---|
| DELETE | location/{id}/weather/config |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
Remove the weather station link from a location. Returns 204 on success.
Required role: viewer or a wider tier at the location, or a read-write share link
Set weather display configuration DRAFT
| Method | Location |
|---|---|
| PUT | location/{id}/weather/display |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
| Body | Optional | Datatype |
|---|---|---|
| (free-form) | no | JSON object |
Set display preferences for weather data at a location (e.g., which weather quantities to show on graphs).
Required role: viewer or a wider tier at the location, or a read-write share link
Location Customization DRAFT
These APIs allow users to customize how locations are displayed: images, floor plan coordinates, graph settings, and derived quantities.
Location images DRAFT
Locations can have two types of images: facade (building photo) and blueprint (floor plan). The image type is specified via the tag query parameter.
Required role: Reading: anyone who sees the location. Uploading and deleting: viewer or a wider tier at the location
Upload image
| Method | Location |
|---|---|
| PUT | location/{id}/image |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| tag | yes | query | String (facade or blueprint, default facade) |
Upload as multipart form data. Maximum image size is 10 MB.
Example response
{
"filename": "office_blueprint.png",
"size": 2048576
}Download image
| Method | Location |
|---|---|
| GET | location/{id}/image |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| tag | yes | query | String (facade or blueprint, default facade) |
Returns the image as binary data with appropriate Content-Type header.
Delete image
| Method | Location |
|---|---|
| DELETE | location/{id}/image |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| tag | yes | query | String (facade or blueprint, default facade) |
Returns 204 on success.
Discovering available images. The images field returned by GET location/tree/my-locations and GET location/{id} lists the tags that have been uploaded for each location (e.g., ["facade"], ["facade", "blueprint"], or [null] if none). Check this field before calling GET to avoid wasted 404 round-trips.
Blueprint coordinates DRAFT
Position rooms on floor plan images. See blueprint-coordinates in the Data (Node Metadata) section for the data format.
Required role: Reading: anyone who sees the location. Setting and deleting: viewer or a wider tier at the location
| Method | Location | Description |
|---|---|---|
| GET | location/{id}/blueprint-coordinates | List all coordinates for this location |
| PUT | location/{id}/blueprint-coordinates | Create or replace coordinates |
| DELETE | location/{id}/blueprint-coordinates | Delete all coordinates |
PUT request/response example:
[
{
"parent-location": 10,
"coords": {"x": 450, "y": 280}
}
]Derived quantities DRAFT
Custom calculated quantities. See derived-quantities in the Data (Node Metadata) section for the concept.
Required role: Reading: anyone who sees the location. Creating, changing and deleting: viewer or a wider tier at the location
| Method | Location | Description |
|---|---|---|
| GET | location/{id}/derived_quantity | List all derived quantities |
| POST | location/{id}/derived_quantity | Create a new derived quantity |
| GET | location/{id}/derived_quantity/{quantity-id} | Get a specific derived quantity |
| PUT | location/{id}/derived_quantity/{quantity-id} | Update a derived quantity |
| DELETE | location/{id}/derived_quantity/{quantity-id} | Delete a derived quantity |
POST/PUT request example:
{
"name": "Comfort Index",
"source": "temperature"
}Graph settings DRAFT
Per-quantity Y-axis configuration. See graph-settings in the Data (Node Metadata) section for the data format.
Required role: viewer or a wider tier at the location, or a read-write share link
| Method | Location | Description |
|---|---|---|
| PUT | location/{id}/graph-settings | Set graph settings for all quantities |
| DELETE | location/{id}/graph-settings/{quantity} | Remove graph settings for a specific quantity |
PUT request example:
{
"graph-settings": {
"temperature": {"y-axis-min": 15.0, "y-axis-max": 30.0},
"co2": {"y-axis-min": 400, "y-axis-max": 2000}
}
}Location Notes DRAFT
Notes are free-text annotations pinned to a location at a specific point in time. They can for example be surfaced as markers on the location measurements graph and are intended for human commentary that helps interpret the data later (e.g. “HVAC filter replaced”, “window left open during cleaning”, “tenant complaint about cold draft”).
Visibility model
Each note has a visibility of either "shared" or "private":
shared— visible to anyone with access to the location. Anyone with location access can edit or delete the note.private— visible only to the author. Only the author can edit or delete the note.
To prevent a user from hiding their own work-in-progress notes from the team without consent (and the inverse), changing visibility into or out of private is restricted to the original author. A non-author can edit a shared note’s body but not flip it to private; nor can they promote someone else’s private note to shared.
Audit fields
Each note records who created it (created-by, created-by-name, created-at) and, once edited, who last edited it (updated-by, updated-by-name, updated-at). Deletion is a soft-delete server-side; deleted notes are not returned by the list endpoint.
Body length
The body field is required, must be non-blank, and is limited to 4000 characters.
List notes DRAFT
| Method | Location |
|---|---|
| GET | location/{id}/notes |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location id) |
| from | no | query | ISO 8601 datetime |
| to | no | query | ISO 8601 datetime |
Returns all non-deleted notes for the location whose event-at falls within [from, to], filtered to those visible to the caller (all shared notes plus the caller’s own private notes). Ordered by event-at ascending.
Example response
[
{
"id": 42,
"location-id": 1234,
"event-at": "2026-04-15T08:30:00Z",
"body": "HVAC filter replaced.",
"visibility": "shared",
"created-at": "2026-04-15T08:31:12Z",
"created-by": 17,
"created-by-name": "Maija Mehiläinen",
"updated-at": "2026-04-15T08:35:02Z",
"updated-by": 17,
"updated-by-name": "Maija Mehiläinen"
},
{
"id": 43,
"location-id": 1234,
"event-at": "2026-04-16T12:00:00Z",
"body": "Tenant reported cold draft near window 3.",
"visibility": "shared",
"created-at": "2026-04-16T12:01:30Z",
"created-by": 21,
"created-by-name": "Pekka Pörhönen",
"updated-at": null,
"updated-by": null,
"updated-by-name": null
}
]updated-at, updated-by, and updated-by-name are null until the note is edited.
Create note DRAFT
| Method | Location |
|---|---|
| POST | location/{id}/notes |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location id) |
Required role: viewer or a wider tier at the location, or a read-write share link
Request body
| Field | Type | Notes |
|---|---|---|
event-at |
ISO 8601 datetime | Required. The point in time the note refers to. |
body |
string | Required. 1–4000 chars, non-blank. |
visibility |
string | Required. "shared" or "private". |
Example request
{
"event-at": "2026-04-15T08:30:00Z",
"body": "HVAC filter replaced.",
"visibility": "shared"
}Returns 201 Created with the created note record (same shape as the list response element).
Update note DRAFT
| Method | Location |
|---|---|
| PATCH | location/{id}/notes/{note-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location id) |
| note-id | no | path | Integer (note id) |
Required role: viewer or a wider tier at the location, or a read-write share link
Request body
| Field | Type | Notes |
|---|---|---|
body |
string | Required. 1–4000 chars, non-blank. |
visibility |
string | Required. "shared" or "private". |
Returns 200 OK with the updated note record. The server sets updated-at, updated-by, and updated-by-name automatically.
Authorisation
404 Not Foundif the note does not exist, has been deleted, or belongs to a different location than the path.403 Forbiddenif the note is currentlyprivateand the caller is not the author.403 Forbiddenif the request would changevisibilityinto or out ofprivateand the caller is not the author.- Otherwise any user with access to the location may update the note.
Delete note DRAFT
| Method | Location |
|---|---|
| DELETE | location/{id}/notes/{note-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location id) |
| note-id | no | path | Integer (note id) |
Soft-deletes the note. Returns 204 No Content. The note will no longer be returned by the list endpoint after deletion.
Required role: viewer or a wider tier at the location, or a read-write share link
Authorisation
404 Not Foundif the note does not exist, is already deleted, or belongs to a different location than the path.403 Forbiddenif the note isprivateand the caller is not the author.- Otherwise any user with access to the location may delete the note.
Surveys (Anonymous Access) DRAFT
Surveys are paged feedback questionnaires that can be answered anonymously. Each survey has multiple pages, and each page contains questions that are either multiple-choice or free-text.
These endpoints are public — no authentication required. Survey locations are identified by a hash ID (not the numeric location ID) to prevent enumeration.
Get survey DRAFT
| Method | Location |
|---|---|
| GET | survey2/anonymous/location/{hashids-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| hashids-id | no | path | String |
Returns the survey structure and location breadcrumb.
Example response
{
"survey": [
{
"title": {"en": "Indoor Air Quality Feedback", "fi": "Sisäilmapalaute"},
"questions": [
{
"question-type": "choice",
"question-text": {"en": "How is the temperature?", "fi": "Miten koet lämpötilan?"},
"choices": [
{"choice-text": {"en": "Too cold", "fi": "Liian kylmä"}, "quality": 1},
{"choice-text": {"en": "Good", "fi": "Hyvä"}, "quality": 5},
{"choice-text": {"en": "Too warm", "fi": "Liian lämmin"}, "quality": 1}
]
},
{
"question-type": "free",
"question-text": {"en": "Other feedback?", "fi": "Muuta palautetta?"}
}
]
}
],
"location": [
{"name": "Office Building", "tag": "building"},
{"name": "Meeting Room", "tag": "room"}
]
}Question types:
choice— Multiple choice with aqualityscore (0–5) per optionfree— Free-form text input
Submit survey response DRAFT
| Method | Location |
|---|---|
| POST | survey2/anonymous/location/{hashids-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| hashids-id | no | path | String |
| Body | Optional | Datatype |
|---|---|---|
| responses | no | Object (question-id → response) |
| datetime | yes | String (RFC3339) |
Submit answers to a survey. Response keys are question indices (integers). Each response is either a choice response or a text response.
Example request
{
"responses": {
"0": {"choice-response": 1},
"1": {"text-response": "The AC is too loud"}
}
}Returns 200 on success.
Internationalization DRAFT
Get translations DRAFT
| Method | Location |
|---|---|
| GET | i18n/translation |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| language | no | query | String (2-char code, e.g. en, fi) |
Returns the UI translation strings for the given language as a nested JSON object. Authentication is optional — unauthenticated users get system default translations.
Example response
{
"en": {
"ui": {
"dashboard": {
"title": "Dashboard",
"no-data": "No data available"
}
}
}
}Short URLs DRAFT
Create short URL DRAFT
| Method | Location |
|---|---|
| POST | short-url |
| Body | Optional | Datatype |
|---|---|---|
| type | no | String (must be share) |
| target-url | no | String (valid URL) |
Create a short URL for sharing. The short URL redirects to the target URL when visited.
Example request
{
"type": "share",
"target-url": "https://app.loopshore.com/share/view?path=campus.office"
}Example response
{
"id": 1,
"code": "aBcDeFgH",
"type": "share",
"target-url": "https://app.loopshore.com/share/view?path=campus.office",
"created-at": "2024-06-01T10:30:00Z",
"visit-count": 0
}The short URL is accessible at https://service.loopshore.com/s/{code}.
Admin APIs DRAFT
These APIs are used for managing users, devices, and locations, and need an administrative role. Each endpoint’s Required role line says which roles may call it; see User Roles for what each role gives.
Scope:
You act only within your organization, and only where your roles are granted. An org-admin covers the whole organization; the other roles cover the location they are granted at and everything below it.
- Users: you see the users who hold a role at your locations, and manage those whose grants all lie where you manage users and give nothing you don’t have there (see Replace user grants)
- Locations: you edit locations below those where you hold
building-admin, or below your organization’s top locations as anorg-admin(see Update node) - Devices: see Device Management APIs
- Theme: an
org-adminmanages their organization’s theme
When trying to access resources outside their scope, the API returns 403 (Forbidden) or 404 (Not Found) — the API does not reveal whether a resource exists if you don’t have access to it.
Admin API Authentication
These APIs use the same authentication methods as User APIs:
- Session cookie: Set by the Login endpoint
- API key: Pass via
x-api-keyheader
Key Concepts
Removed user fields
Access comes from grants: see User Roles, permissions in Get current user, and Get user grants. Three user fields from the older model were removed in version 0.66.0:
| Field | Was in | Use instead |
|---|---|---|
nodes |
Get current user, user responses, Create user, Update user | permissions (and permissions.share.location-id for a share link); Get user grants and Replace user grants |
profile.privileges |
Get current user, user responses, Create user, Update user | The alarm-subscriber and threshold-editor grants |
role value admin |
Get current user, user responses, Create user, Update user | The org-admin grant; widest-role in List users |
- Responses don’t include
nodesorprofile.privileges, androleisuserfor every user. - Create user and Update user answer
400to a request that sendsnodesorprofile.privileges, or aroleother than the user’s current one. Sending the currentroleis accepted and changes nothing. - An
org-admin’s list of locations shown in the measurements tree, which was saved innodes, is their tree preference: see Profile.
Theme
The theme field names the theme configuration that controls:
- Email templates (alarm notifications, password reset, activation emails)
- SMS templates
- SMTP settings for sending emails
- Branding (service domain, sender names)
"theme": "loopshore"The theme belongs to the organization: a user in one has its theme, which Loopshore sets, and reads return it.
Rules:
themein Create user and Update user is deprecated. For a user in an organization, a request may send the organization’s theme ornull, which changes nothing; a different theme answers400- A theme that doesn’t exist answers
400 - If not set, defaults to
"loopshore"
Use GET /api/theme-config/themes to see the themes you can configure.
Profile
The profile field stores user preferences as a JSON object. Its settings hold the display preferences that Update settings writes:
"profile": {
"settings": {
"temperature-unit": "celsius",
"tree-locations": [12, 47]
}
}| Field | Type | Description |
|---|---|---|
settings |
object | Display preferences: the units of Update settings, and tree-locations |
settings.tree-locations |
int[] | null | The tree preference: the locations whose subtrees the measurements tree shows, in this order. Empty, null or missing shows everything you can see. Entries you can’t see, archived and deleted locations are skipped; a preference with none left shows everything. It changes what is shown, not what you may access |
Update user replaces the profile’s other keys with those sent and merges settings key by key, so settings not sent stay.
Note: The logo, title, and initial-view fields may appear in older data but are deprecated and not used by current UI.
Path (Location Hierarchy)
The path field defines a node’s position in the location hierarchy using dot-separated labels (PostgreSQL ltree type).
"path": "campus.building_a.floor_2.room_201"
Rules:
- Each path segment is alphanumeric (no spaces or special characters except underscore)
- Path must be unique across all nodes
- Parent path must exist before creating a child (e.g.,
campus.building_amust exist before creatingcampus.building_a.floor_1) - Hierarchy is derived from path structure, not stored separately
Examples:
| Path | Represents |
|---|---|
helsinki |
Top-level site |
helsinki.office_building |
Building within site |
helsinki.office_building.floor_3 |
Floor within building |
helsinki.office_building.floor_3.room_301 |
Room on floor |
Tag (Node Type)
Valid node type values:
| Tag | Ordinal | Description |
|---|---|---|
site |
10 | Top-level grouping (campus, property) |
building |
20 | Physical building |
staircase |
30 | Staircase/entrance |
floor |
40 | Floor level |
room |
50 | Individual room/space |
Hierarchy rule: A node’s tag ordinal should be greater than its parent’s. For example, a room (50) should not contain a building (20) as a child.
Data (Node Metadata)
Each node has a data field storing location-specific configuration. UI reads and updates these via dedicated APIs.
quantity-names
Custom display names for measurements. A map where keys are quantity identifiers and values are custom labels shown in UI instead of the default names.
{
"temperature": "Supply Air Temp",
"temperature_1": "Return Air Temp"
}Use when the default quantity name is too generic (e.g., distinguishing “Supply Air Temp” from “Return Air Temp” when both use the generic “temperature” quantity).
This field is exposed in the quantities-v2 response under the name alternative-names (see Extended User APIs → Get quantity metadata).
quantities
Quantity visibility whitelist. An array of quantity identifiers that should be visible for this location. When set, only these quantities appear in graphs and dashboards.
["temperature", "humidity", "co2"]Use when a device reports many quantities but only some are relevant. For example, a multi-sensor might report 20 quantities but only 3 matter for air quality monitoring.
Important: This is a UI-only setting. Backend APIs still return all data regardless of the whitelist.
Note: This data is stored in the node’s data field. To read or modify it via API, use the dedicated quantity whitelist endpoints (GET/PUT/DELETE /api/location/{id}/quantity-whitelist) rather than editing the node’s data field directly.
graph-settings
Per-quantity Y-axis configuration. A map where keys are quantity identifiers and values are objects with axis bounds.
{
"temperature": {
"y-axis-min": 15.0,
"y-axis-max": 30.0
},
"co2": {
"y-axis-min": 400,
"y-axis-max": 2000
}
}| Key | Type | Description |
|---|---|---|
y-axis-min |
number | Minimum value for Y-axis. If omitted, auto-scales. |
y-axis-max |
number | Maximum value for Y-axis. If omitted, auto-scales. |
Users set these to maintain consistent graph scales instead of auto-scaling, making it easier to compare values across time.
blueprint-coordinates
Room position on floor plan images. An array of coordinate mappings that position this room on parent locations’ blueprint images.
[
{
"parent-location": 123,
"coords": {"x": 450, "y": 280}
}
]| Key | Type | Description |
|---|---|---|
parent-location |
integer | ID of the parent location whose blueprint image this coordinate refers to |
coords.x |
integer | Horizontal pixel position on the blueprint image |
coords.y |
integer | Vertical pixel position on the blueprint image |
Used by floor plan visualization to show measurement points at their correct physical positions. A room can have coordinates for multiple parent blueprints (e.g., both floor-level and building-level views).
derived-quantities
Calculated measurements. An array of custom quantities computed from source measurements at this location.
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Comfort Index",
"source": "temperature",
"formula": "linear",
"parameters": {"slope": 1.5, "offset": -10}
}
]| Key | Type | Description |
|---|---|---|
id |
string (UUID) | Unique identifier. Must remain stable for data continuity. |
name |
string | Display name shown in UI |
source |
string | Source quantity identifier to derive from |
formula |
string | Calculation type (e.g., “linear”, “threshold”) |
parameters |
object | Formula-specific parameters |
Used to create custom metrics from raw sensor data, such as comfort indices or normalized values. The UUID is critical — changing it breaks historical data associations.
User Management APIs DRAFT
Send activation email DRAFT
| Method | Location |
|---|---|
| POST | password/activate |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| name | no | query | String |
| validDays | yes | query | Integer |
Send activation email to a user with password reset link. Used to onboard a newly-created user who has not yet set a password.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example request
POST /api/password/activate?name=newuser&validDays=7
Success response
{
"message": "Activation email sent"
}Send password reset email DRAFT
| Method | Location |
|---|---|
| POST | password/send-reset-email |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| name | no | query | String |
| validDays | yes | query | Integer (default 14) |
Email a password reset link to an existing user you manage. Intended for helping a user who has lost access or locked themselves out. The token is long-lived (14 days by default) so the user has time to react and so you can safely resend. Not rate-limited. Returns 404 for unknown users and users you don’t manage — enumeration defenses are unnecessary, since only users who manage users may call it.
Required role: org-admin, or building-admin at some location (see Get user grants)
Use one of the four password-related endpoints based on who initiates the flow:
POST password/reset-request— public self-service: unauthenticated, 1-hour token, rate-limited, always returns 200 (anti-enumeration).POST password/activate— onboarding: sends an activation email to a newly-created user who has not set a password yet.POST password/send-reset-email— recovery: emails a 14-day reset link to an existing user.GET password/reset— copy-paste: returns the reset link without sending email (for out-of-band delivery).
Example request
POST /api/password/send-reset-email?name=alice&validDays=14
Success response
{
"extra-msg": "Password reset email sent to alice@example.com"
}Get password reset link DRAFT
| Method | Location |
|---|---|
| GET | password/reset |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| name | no | query | String |
| validDays | yes | query | Integer |
Get a password reset link for a user (does not send email). Useful when you need to deliver the link out-of-band (SMS, chat) rather than via email.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
{
"reset-ui-url": "https://app.loopshore.com/reset-password?secret=abc123xyz"
}List users DRAFT
| Method | Location |
|---|---|
| GET | user |
List all users in your scope: every user of your organization for an org-admin, and for a building-admin the users holding a role in their buildings. A listed user you can’t manage, because they also have access elsewhere or more rights than you, has manageable false: you can read them, and changing, suspending, deleting or impersonating them returns 403.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
[
{
"id": 1,
"name": "facility@example.com",
"role": "user",
"email": "facility@example.com",
"language": "en",
"theme": "loopshore",
"profile": {},
"emailConfirmed": true,
"lastlogin": "2024-06-02T00:00:00Z",
"last-credential-login-at": "2024-05-28T08:00:00Z",
"last-refresh-at": "2024-06-01T10:30:00Z",
"last-device-info": "Pixel 9 Pro",
"last-api-key-used-at": "2024-06-02T00:00:00Z",
"last-webhook-delivered-at": null,
"last-alarm-sent-at": "2024-05-30T06:12:00Z",
"created-at": "2023-11-14T12:00:00Z",
"created-at-estimated": true,
"active": true,
"password-reset-pending": false,
"activation-email": {
"sent-at": "2024-05-28T09:15:00Z",
"sent-by-name": "admin",
"status": "ok",
"error-message": null
},
"password-reset-email": null,
"manageable": true,
"widest-role": "building-admin"
}
]Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | User ID |
name |
string | Username |
role |
string | user. Use widest-role for what the user may do (Removed user fields). |
email |
string | Email address |
language |
string | UI language code |
theme |
string | Theme identifier |
profile |
object | User preferences; see Profile |
emailConfirmed |
boolean | Whether email has been verified |
phonenum |
string | Phone number |
lastlogin |
datetime | null | Last activity — the latest of last-credential-login-at, last-refresh-at, last-api-key-used-at, last-webhook-delivered-at and last-alarm-sent-at. null if none has occurred. |
last-credential-login-at |
datetime | null | Last time the user completed a username/password login (web or mobile). null if never. |
last-refresh-at |
datetime | null | Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started. |
last-device-info |
string | null | device_info string from the most recent mobile session. null if no mobile session has ever been started. |
last-api-key-used-at |
datetime | null | Last time any of the user’s API keys, share links included, authenticated a request. Updated at most once a day per key, so it can lag by up to a day. null if never recorded. |
last-webhook-delivered-at |
datetime | null | Last successful delivery of any of the user’s webhooks. Updated at most once a day per webhook. null if never recorded. |
last-alarm-sent-at |
datetime | null | Last time an alarm notification (email or SMS) was sent to the user. null if never. |
created-at |
datetime | null | When the user was created. null if unknown. |
created-at-estimated |
boolean | true when created-at is an estimate for a user created before creation dates were recorded: the user existed by then, judging by the earliest related record. |
feature-overrides |
object | The user’s own settings for paid features. {"ai-helper": false}: the AI helper is off for the user. No ai-helper key: the user follows the organization. See Set user AI helper setting. |
suspended-at |
datetime | When the user was suspended. Present only for suspended users. See Suspend user. |
suspended-by |
integer | ID of the user who suspended this user. Present only for suspended users, while that user exists. |
suspended-by-name |
string | Username of the user who suspended this user. Present only with suspended-by. |
active |
boolean | true once the user has set a password |
password-reset-pending |
boolean | true while a reset token is outstanding; flips to false when the user consumes the reset link |
activation-email |
object | null | Most recent activation email sent to this user, or null if none has been recorded |
password-reset-email |
object | null | Most recent password-reset email sent to this user, or null if none has been recorded |
manageable |
boolean | Whether you may change, suspend, delete or impersonate the user. false for a user you see but don’t manage. |
widest-role |
string | The widest role the user holds: org-admin, building-admin, operator, viewer, or guest-viewer when that is all they hold. Add-on roles aren’t counted. Omitted for a user without access. |
The activation-email and password-reset-email objects (when non-null) contain the most recent send attempt for the matching email_type:
| Field | Type | Description |
|---|---|---|
sent-at |
datetime | When the send was attempted |
sent-by-name |
string | null | Username of the user who triggered the send; null for self-service (public reset-request) |
status |
string | ok, error, or dev_mode |
error-message |
string | null | Short error description when status = error; otherwise null |
Create user DRAFT
| Method | Location |
|---|---|
| POST | user |
| Body | Optional | Datatype |
|---|---|---|
| name | no | String |
| yes | String | |
| password | yes | String |
| role | yes | String |
| language | yes | String |
| theme | yes | String |
| profile | yes | JSON object |
| grants | yes | JSON array |
Create a new user. If password is omitted, user must reset via activation email.
Give the user’s roles in grants. role is user, or may be left out. nodes, profile.privileges and any other role answer 400 (Removed user fields).
Required role: org-admin, or building-admin at some location (see Get user grants)
grants gives the new user’s grants, as Replace user grants takes them, stored together with the user. If any grant is refused (400 or 403, as there), the user isn’t created. If you manage users in some locations only, the new user needs a role in at least one of them, since you see only the users who hold one: otherwise 400.
Example request
{
"name": "maintenance@example.com",
"email": "maintenance@example.com",
"language": "fi",
"grants": [
{"role": "operator", "location": 25}
]
}Success response
{
"id": 15,
"name": "maintenance@example.com"
}Typical workflow:
- Create user with
POST /api/user - Send activation email with
POST /api/password/activate?name=maintenance@example.com - User clicks link in email and sets their password
Get user DRAFT
| Method | Location |
|---|---|
| GET | user/{user-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
Get user details by ID.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
{
"id": 15,
"name": "user@example.com",
"email": "user@example.com",
"role": "user",
"language": "en",
"theme": "loopshore",
"profile": {},
"emailConfirmed": false,
"phonenum": "+358501234567",
"lastlogin": "2024-05-15T14:20:00Z",
"last-credential-login-at": "2024-05-15T14:20:00Z",
"last-refresh-at": "2024-05-14T09:05:00Z",
"last-device-info": "iPhone 15 Pro",
"last-api-key-used-at": null,
"last-webhook-delivered-at": null,
"last-alarm-sent-at": "2024-05-10T07:45:00Z",
"created-at": "2024-04-02T09:30:00Z",
"created-at-estimated": false,
"active": true,
"password-reset-pending": false
}Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | User ID |
name |
string | Username |
role |
string | user. Use Get user grants for what the user may do (Removed user fields). |
email |
string | Email address |
language |
string | UI language code |
theme |
string | Theme identifier |
profile |
object | User preferences; see Profile |
emailConfirmed |
boolean | Whether email has been verified |
phonenum |
string | Phone number |
lastlogin |
datetime | null | Last activity — the latest of last-credential-login-at, last-refresh-at, last-api-key-used-at, last-webhook-delivered-at and last-alarm-sent-at. null if none has occurred. |
last-credential-login-at |
datetime | null | Last time the user completed a username/password login (web or mobile). null if never. |
last-refresh-at |
datetime | null | Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started. |
last-device-info |
string | null | device_info string from the most recent mobile session. null if no mobile session has ever been started. |
last-api-key-used-at |
datetime | null | Last time any of the user’s API keys, share links included, authenticated a request. Updated at most once a day per key, so it can lag by up to a day. null if never recorded. |
last-webhook-delivered-at |
datetime | null | Last successful delivery of any of the user’s webhooks. Updated at most once a day per webhook. null if never recorded. |
last-alarm-sent-at |
datetime | null | Last time an alarm notification (email or SMS) was sent to the user. null if never. |
created-at |
datetime | null | When the user was created. null if unknown. |
created-at-estimated |
boolean | true when created-at is an estimate for a user created before creation dates were recorded: the user existed by then, judging by the earliest related record. |
feature-overrides |
object | The user’s own settings for paid features. {"ai-helper": false}: the AI helper is off for the user. No ai-helper key: the user follows the organization. See Set user AI helper setting. |
suspended-at |
datetime | When the user was suspended. Present only for suspended users. See Suspend user. |
suspended-by |
integer | ID of the user who suspended this user. Present only for suspended users, while that user exists. |
suspended-by-name |
string | Username of the user who suspended this user. Present only with suspended-by. |
active |
boolean | true once the user has set a password |
password-reset-pending |
boolean | true while a reset token is outstanding; flips to false when the user consumes the reset link |
The activation-email and password-reset-email audit fields are only included on List users.
Update user DRAFT
| Method | Location |
|---|---|
| PUT | user/{user-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
| Body | Optional | Datatype |
|---|---|---|
| name | yes | String |
| yes | String | |
| password | yes | String |
| role | yes | String |
| language | yes | String |
| theme | yes | String |
| profile | yes | JSON object |
| phonenum | yes | String |
Update user details. Only include fields you want to change. Change the user’s roles with Replace user grants: this endpoint doesn’t take grants.
Required role: org-admin, or building-admin at some location (see Get user grants)
role may repeat the user’s current one, which changes nothing. nodes, profile.privileges and a different role answer 400 (Removed user fields). A profile replaces the user’s other profile keys and merges settings key by key (see Profile); null changes nothing.
Returns 403 for a user you see but may not manage, and 404 for a user you can’t see.
Example request
{
"language": "fi",
"phonenum": "+358501234567"
}Delete user DRAFT
| Method | Location |
|---|---|
| DELETE | user/{user-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
Delete a user. Returns 204 on success.
Required role: org-admin, or building-admin at some location (see Get user grants)
Deleting a user also deletes their API keys and share links, webhooks, alarm subscriptions and history, and dashboards. To stop an account without losing any of these, suspend it instead. Get user impact lists what would be deleted, with the last use of each item.
Suspend user DRAFT
| Method | Location |
|---|---|
| PUT | user/{user-id}/suspension |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
Suspend a user without deleting anything. No request body. While suspended, the user:
Required role: org-admin, or building-admin at some location (see Get user grants)
- gets
403 {"error": "user_suspended"}on every authenticated request, whatever credentials are used: session cookie, Bearer token or API key. The user’s share links return403 {"error": "share_unavailable"}. Credentials issued before the suspension stop working immediately. - gets the same error at login, only after a correct password, and at token refresh, which does not consume the refresh token.
- receives no webhook deliveries and no alarm notifications. Alarm states keep following the measurements, so no stale alarm is sent after unsuspending.
You can suspend the users you manage (see Replace user grants), but not yourself (400); others return 403. Suspending an already suspended user keeps the original suspended-at and suspended-by.
Success response
The user, same shape as Get user, with suspended-at, suspended-by and suspended-by-name set.
Error responses
| Status | Description |
|---|---|
| 400 | Tried to suspend yourself |
| 403 | The user is outside your group |
Unsuspend user DRAFT
| Method | Location |
|---|---|
| DELETE | user/{user-id}/suspension |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
Restore a suspended user. Login, existing sessions and refresh tokens, API keys, share links, webhooks and alarm notifications all work again as before. Returns the user, same shape as Get user, without the suspension fields. Same access rules and errors as Suspend user.
Required role: org-admin, or building-admin at some location (see Get user grants)
Get user impact DRAFT
| Method | Location |
|---|---|
| GET | user/{user-id}/impact |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
What suspending or deleting a user affects, with the last use of each item, so you can check whether the account is still in use. Suspension stops the share links, API keys, webhooks and alarm subscriptions; deletion also removes the dashboards and the group membership. Last-use times for API keys, share links and webhooks are updated at most once a day.
Required role: org-admin, or building-admin at some location (see Get user grants)
You can read every user on your List users, yourself included; others return 403.
Success response
{
"user-id": 42,
"group": {"id": 7, "name": "Example Oy"},
"share-links": [
{"id": 311, "key-name": "Lobby screen", "purpose": "sharer", "read-only": false,
"created-at": "2025-11-03T08:12:44Z", "last-used-at": "2026-10-05T06:30:12Z",
"location-id": 1203, "location-name": "Lobby"}
],
"api-keys": [
{"id": 298, "key-name": "BMS integration", "purpose": "all", "read-only": true,
"created-at": "2025-06-17T10:01:09Z", "last-used-at": null}
],
"webhooks": [
{"id": "0b6f4c1e-5d2a-4f7e-9a51-3c8e2d7f1a90", "host": "hooks.example.com",
"description": "Building automation", "scope": "all", "device-id": null,
"last-delivered-at": "2026-10-06T04:00:00Z"}
],
"alarm-subscriptions": [
{"id": 88, "location-id": 1207, "location-name": "Meeting room 2", "quantity": "co2",
"config-type": "location_threshold", "enabled": true, "last-sent-at": "2026-09-28T13:45:00Z"}
],
"dashboards": [
{"id": "6a1d9e2b-0c4f-4b8a-8f3e-2d5c7b9a1e64", "name": "Overview", "visibility": "private",
"updated-at": "2026-08-14T09:20:31Z"}
]
}| Field | Type | Description |
|---|---|---|
group |
object | The user’s group (id, name), or null |
share-links |
array | Share links: key-name, purpose, read-only, created-at, last-used-at (null = never used), and the shared location-id and location-name |
api-keys |
array | The user’s other API keys, same fields without the location |
webhooks |
array | Webhook subscriptions with last-delivered-at, the last successful delivery. Only the URL’s host is returned |
alarm-subscriptions |
array | Alarm subscriptions with location-id, location-name, quantity, config-type, enabled and last-sent-at, when an alarm notification was last sent |
dashboards |
array | Dashboards with name, visibility and updated-at |
Error responses
| Status | Description |
|---|---|
| 403 | The user is outside your group |
| 404 | No such user |
Set user AI helper setting DRAFT
| Method | Location |
|---|---|
| PUT | user/{user-id}/feature-override |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
| Body | Optional | Datatype | Description |
|---|---|---|---|
| feature | no | String | ai-helper |
| enabled | yes | Boolean | null | false switches the AI helper off for the user. Omit it, or send null, to have the user follow the organization again. |
The AI helper is a paid feature of the organization. While your organization has it, every user gets it unless their own setting is off. An org-admin can switch it off for the users they manage, and back to following the organization, only while the organization has the AI helper. A building-admin gets 403.
Required role: org-admin
Example request
{
"feature": "ai-helper",
"enabled": false
}Example response
The user’s settings after the change:
{
"ai-helper": false
}Error responses
| Status | Description |
|---|---|
| 400 | Unknown feature |
| 403 | The user is outside your group, your organization doesn’t have the AI helper, or enabled is true |
Get user operation stats DRAFT
| Method | Location |
|---|---|
| GET | user/{user-id}/operation-stats |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
| operation-type | yes | query | String |
| start | yes | query | String (RFC3339) |
| end | yes | query | String (RFC3339) |
Get throttled operation statistics for a user (SMS sending, password resets, etc.).
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
{
"total": [
{"opertype": "sms", "total-count": 12},
{"opertype": "password-reset", "total-count": 3}
],
"data": [
{"hourbucket": "2024-05-15T14:00:00Z", "count": 2, "opertype": "sms"}
]
}Check username availability DRAFT
| Method | Location |
|---|---|
| GET | check-username |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| username | no | query | String |
Check if a username is available for new user creation.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
{
"available": true
}Impersonate user DRAFT
| Method | Location |
|---|---|
| POST | imposter |
| Body | Optional | Datatype |
|---|---|---|
| name | no | String |
Login as another user for support purposes. You may impersonate a user you manage: every role they hold lies where you manage users and gives nothing you lack there, so an org-admin may impersonate another org-admin of the organization. A refusal is 403 for a user on your List users, 404 for others.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example request
{
"name": "user@example.com"
}Success response
Sets a session cookie for the target user, same as regular login.
{
"user": "user@example.com",
"ts": "2024-06-01T10:30:00Z"
}Impersonating a suspended user returns 409 {"error": "user_suspended"} and leaves your own session untouched.
While impersonating you act fully as the target user: everything done in the session is recorded as theirs. The session ends when your own account is suspended (403 {"error": "user_suspended"}), deleted or no longer allowed to impersonate anyone (401).
Users with access DRAFT
| Method | Location |
|---|---|
| GET | access/users-with-access |
Who can view each location where you manage users, by location ID. Only users of your own organization are listed. via says whether the access comes from the location or a location above it (location), from organization-wide access (group), or both. role is the user’s widest tier there (see Get user grants). Archived locations, and locations nobody can view, are left out. Returns 403 if you can’t manage users anywhere.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
{
"locations": {
"12": [
{"id": 7, "name": "admin@example.com", "via": ["group"], "role": "org-admin"},
{"id": 9, "name": "user@example.com", "via": ["location"], "role": "viewer"}
]
}
}Get user grants DRAFT
| Method | Location |
|---|---|
| GET | user/{user-id}/grants |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
A user’s grants: what they may do, and where. A grant is a role at a location, which also covers every location below it, or org-admin on your organization. Each grant says who granted it (granted-by, a user ID, null when unknown) and when, and whether you may add or remove it (editable).
Required role: org-admin, or building-admin at some location (see Get user grants)
| Role | Kind | What it gives |
|---|---|---|
guest-viewer |
Tier | Viewing a location in another organization. Set by Loopshore; always read-only here. |
viewer |
Tier | Viewing and naming locations, notes, display settings, status alarms, personal API keys, webhooks and share links. |
operator |
Tier | Viewer, plus installing, removing and configuring devices. |
building-admin |
Tier | Operator, plus editing locations below, managing users, quantity alarms and alarm thresholds, and surveys. |
org-admin |
Tier | Everything in your organization. Granted on the organization (group), not a location. |
alarm-subscriber |
Add-on | Quantity alarm subscriptions. On a guest-viewer grant, quantity and device-status alarm subscriptions; set there by Loopshore. |
threshold-editor |
Add-on | Editing alarm thresholds. |
device-configurator |
Add-on | Configuring devices. |
An add-on gives nothing without a tier at or above its location.
Readable for the users you see in List users. Returns 404 for other users.
Example response
{
"grants": [
{"role": "viewer", "location": 12, "editable": true,
"granted-by": 7, "granted-at": "2026-10-19T08:15:00Z"},
{"role": "alarm-subscriber", "location": 31, "editable": true,
"granted-by": 7, "granted-at": "2026-10-19T08:15:00Z"}
]
}Replace user grants DRAFT
| Method | Location |
|---|---|
| PUT | user/{user-id}/grants |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| user-id | no | path | Integer |
| Body | Optional | Datatype | Description |
|---|---|---|---|
| grants | no | Array | The user’s grants after the change: each a role with a location ID, or org-admin with your organization’s group ID |
Replaces the user’s grants with the set given. Only grants you may add or remove change: those the response of Get user grants marks editable. Others stay whether the set lists them or not. Unchanged grants keep who granted them and when. Returns the user’s grants as Get user grants does.
Required role: org-admin, or building-admin at some location (see Get user grants)
You may add or remove a grant only where you manage users and hold every permission its role gives. An organization admin grants any role in the organization; a building-admin grants roles up to building-admin within their locations.
- An
org-adminholds noviewergrant in their organization, nor the add-ons that only such a grant carries:org-admincovers them. A request that gives them to anorg-admin, or addsorg-adminto a user who has them, stores the user without them. The response lists the grants as stored. - Removing
org-adminleaves the user with the other grants in the set. Include the user’s new access in the same request. - A location grant, of any role, is given at a building or at a location above buildings, such as a site: never at a location inside a building, such as a floor or a room. Grants given inside a building earlier stay, are listed as before and can be removed.
- An add-on needs a tier at or above its location. Removing a tier also removes the add-ons it carried, unless another tier covers them.
Errors:
400: an unknown role, a role without itslocationorgroup, an unknown or archived location, a new grant at a location inside a building, a new add-on without a tier, removing your ownorg-admingrant, or, if you manage users in some locations only, leaving the user without a role in any of them (you would no longer see the user).403: a grant you may not add or remove, which includes any grant outside your organization, or a user you may see but not manage (one with access outside your locations).404: a user you can’t see.
Nothing is stored when the request is refused.
Example request
{
"grants": [
{"role": "viewer", "location": 12},
{"role": "operator", "location": 31},
{"role": "alarm-subscriber", "location": 31}
]
}Node/Location Management APIs DRAFT
List nodes DRAFT
| Method | Location |
|---|---|
| GET | node |
List the locations you manage or install devices at, with every location below them, archived ones included: your organization’s locations for an org-admin, a building-admin’s buildings, an operator’s locations. Returns a flat list.
Required role: org-admin, or building-admin or operator at some location (see Get user grants)
Example response
[
{
"id": 1,
"path": "helsinki",
"name": "Helsinki Campus",
"data": {"address": "Example Street 1"},
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"tag": "site",
"images": [null],
"archived_at": null
},
{
"id": 5,
"path": "helsinki.office",
"name": "Office Building",
"data": {},
"uuid": "550e8400-e29b-41d4-a716-446655440001",
"tag": "building",
"images": ["blueprint", "facade"],
"archived_at": "2026-04-10T08:30:00Z"
}
]Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | Node ID (used in nodes array for user access) |
path |
string | Hierarchical path |
name |
string | Display name |
data |
object | Metadata JSON |
uuid |
string | UUID for external system references |
tag |
string | Node type |
images |
string[] | Image tags attached to this location |
archived_at |
string|null | ISO 8601 timestamp when the node was archived, or null if active |
To build a tree structure, use the path field - child nodes have paths that start with their parent’s path.
Create node DRAFT
| Method | Location |
|---|---|
| POST | node |
| Body | Optional | Datatype |
|---|---|---|
| path | no | String |
| name | no | String |
| tag | yes | String |
| data | yes | JSON object |
Create a new node. The parent path must already exist.
Required role: org-admin, or building-admin above the new location
Example request
{
"path": "helsinki.office.floor_2",
"name": "Floor 2",
"tag": "floor",
"data": {
"area_sqm": 450
}
}Success response
{
"id": 26,
"path": "helsinki.office.floor_2",
"name": "Floor 2",
"data": {"area_sqm": 450},
"tag": "floor",
"uuid": "550e8400-e29b-41d4-a716-446655440003"
}Get node DRAFT
| Method | Location |
|---|---|
| GET | node/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
Get node details by ID.
Required role: org-admin, or building-admin at the location or above it
Example response
{
"id": 25,
"path": "helsinki.office.floor_1",
"name": "Floor 1",
"data": {"area_sqm": 500},
"tag": "floor",
"uuid": "550e8400-e29b-41d4-a716-446655440002"
}Update node DRAFT
| Method | Location |
|---|---|
| PATCH | node/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| Body | Optional | Datatype |
|---|---|---|
| name | yes | String |
| path | yes | String |
| tag | yes | String |
| data | yes | JSON object |
Update node properties. Changing path moves the node in the hierarchy.
Required role: org-admin, or building-admin at the location or above it; see Who may change what below
data is merged into the node’s stored data: the keys you send replace those keys, and the other keys stay. A key sent as null is removed. data.address is a string of at most 500 characters: anything else returns 400.
Who may change what: you need location edit at the node (org-admin, or building-admin at the node or above it). Moving the node, and changing its data beyond address, also need location edit above it, as do deleting and archiving it. So the location a role is given at changes its own name and address, and its structure is changed from the level above. Otherwise 403.
Move restrictions: Moving a node across an archive boundary is not allowed. You cannot move an active node under an archived parent, or an archived node under an active parent. The API returns HTTP 400 with a descriptive message if the move would violate this rule.
Example request
{
"name": "Floor 1 - Main Office",
"data": {"address": "Example Street 1"}
}Delete node DRAFT
| Method | Location |
|---|---|
| DELETE | node/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| Body | Optional | Datatype |
|---|---|---|
| path | no | String |
Permanently delete a node and all its children. The path must match the node’s actual path (serves as confirmation).
Required role: org-admin for a location below the organization’s top locations, or building-admin above the location
Warning: This is a cascading delete. All child nodes, device installations, alarms, and images will be permanently deleted. Consider using Archive node instead for a reversible operation.
Archive node DRAFT
| Method | Location |
|---|---|
| POST | node/archive/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| Body | Optional | Datatype |
|---|---|---|
| path | no | String |
Archive a node and all its descendants (soft-delete). The path must match the node’s actual path (serves as confirmation). Archived locations are hidden from everyone but those who edit them (org-admin, or building-admin above them), stop generating analyses and alarms, and do not trigger webhooks or public surveys. All data is preserved and can be restored by unarchiving.
Required role: org-admin for a location below the organization’s top locations, or building-admin above the location
Returns 409 Conflict if the node is already archived.
Example request
{
"path": "helsinki.office"
}Example response
[
{"id": 5, "path": "helsinki.office", "name": "Office Building"},
{"id": 25, "path": "helsinki.office.floor_1", "name": "Floor 1"},
{"id": 26, "path": "helsinki.office.floor_2", "name": "Floor 2"}
]The response lists all nodes that were archived (the target node and its descendants).
Effects of archiving:
- Archived locations are excluded from all user-facing API responses (location tree, observations, analyses, alarms, webhooks)
- Existing device installations are preserved but the device’s location is hidden from user APIs
- Analysis data for archived rooms is cleaned up during the next re-analysis cycle
- Alarm processing skips archived locations (no false “no data” alarms)
- Public survey endpoints return 404 for archived locations
Unarchive node DRAFT
| Method | Location |
|---|---|
| POST | node/unarchive/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
Restore an archived node and all its archived descendants. The node becomes visible to users again and resumes normal operation (analyses, alarms, webhooks, surveys).
Required role: org-admin for a location below the organization’s top locations, or building-admin above the location
Returns 400 if the node is not archived, or if the parent node is still archived (unarchiving must be done top-down).
Example response
[
{"id": 5, "path": "helsinki.office", "name": "Office Building"},
{"id": 25, "path": "helsinki.office.floor_1", "name": "Floor 1"},
{"id": 26, "path": "helsinki.office.floor_2", "name": "Floor 2"}
]The response lists all nodes that were unarchived.
Quantity Whitelist APIs DRAFT
These endpoints manage the data.quantities field of a location node. See the quantities section under Data (Node Metadata) for details on what this setting controls.
Get quantity whitelist DRAFT
| Method | Location |
|---|---|
| GET | location/{id}/quantity-whitelist |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
Get the quantity whitelist for a location. Returns 404 if no whitelist is set (using system defaults).
Required role: org-admin, or building-admin at the location or above it
Example response
["temperature", "humidity", "co2", "pm2p5"]Set quantity whitelist DRAFT
| Method | Location |
|---|---|
| PUT | location/{id}/quantity-whitelist |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
| Body | Optional | Datatype |
|---|---|---|
| (array) | no | String[] |
Set or update the quantity whitelist for a location.
Required role: org-admin, or building-admin at the location or above it
Example request
["temperature", "humidity", "co2"]Note: This is a UI-only setting. Backend APIs still return all data; the frontend filters what to display.
Remove quantity whitelist DRAFT
| Method | Location |
|---|---|
| DELETE | location/{id}/quantity-whitelist |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer |
Remove the quantity whitelist, reverting to system defaults. Returns 204 on success.
Required role: org-admin, or building-admin at the location or above it
Device Management APIs DRAFT
About
device-id. A device’sdevice-id(equivalently itsendpointin the LWM2M APIs) is its unique identifier — the same value printed on the device sticker as the serial number (S:) and shown to end users as “Device ID” (FI “Laitetunnus”, SV “Enhets-ID”). It originates from the modem IMEI and never changes.
Device Installation Model
Each installation is a time-range record that tracks when a device was at a location. An installation has:
| Field | Description |
|---|---|
installation-id |
Unique identifier for the record |
device-id |
Which device |
location |
Where it was installed (location ID) |
installation-time |
When the installation started |
removal-time |
When the installation ended (null = still installed) |
Time intervals are half-open: [installation-time, removal-time). A device is considered installed at a location from its installation-time up to (but not including) its removal-time.
Active installations: A device is “currently installed” when removal-time is null or in the future. Setting a future removal-time schedules a removal — the device remains active until that time passes.
Constraints:
- A device cannot be at two locations at the same time — installation time ranges must not overlap
- A location cannot have two devices at the same time — the API returns 400 if an install or update would cause an overlap
Operations:
| Operation | API | Effect |
|---|---|---|
| Install | POST device/{id}/installation |
Creates a new installation record |
| Remove | POST device/{id}/removal |
Sets removal-time on the active installation |
| Edit | PUT installations/{id} |
Updates times on an existing record |
| Delete | DELETE installations/{id} |
Erases the record entirely |
Example: device-001 installation history
| installation-id | location | installation-time | removal-time |
|---|---|---|---|
| 150 | 25 | 2024-01-01T09:00:00Z | 2024-03-15T14:00:00Z |
| 151 | 30 | 2024-03-15T14:00:00Z | 2024-06-01T10:00:00Z |
| 152 | 30 | 2024-09-01T08:00:00Z | null |
Reading this: device-001 was at location 25 from Jan to Mar, at location 30 from Mar to Jun, uninstalled from Jun to Sep, then reinstalled at location 30 where it remains.
This model allows querying which device was at a specific location at any point in time (useful for historical data analysis).
Product Codes
The device-type field typically contains a product code — an 11-character structured identifier that encodes device specifications for Loopshore devices. For other vendors’ devices, this field may contain different identifiers.
List devices DRAFT
| Method | Location |
|---|---|
| GET | device |
List the devices your organization holds now, held in the past, or is due to hold.
If you install or configure devices at some locations only (for example as a building-admin, an operator or with device-configurator), the list has the devices your organization holds now that are installed now at those locations or below them.
For a device your organization doesn’t hold now, location, location-name, installation-time and path are null and notable-flags is omitted, even if the device is installed for another organization.
Required role: org-admin, or operator, building-admin or device-configurator at some location (see Get user grants)
Query parameters:
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| include-notable-flags | yes | query | Boolean (default false) |
When include-notable-flags=true, each device that has one or more notable settings carries a notable-flags array (see Notable device settings). Flags are computed from the device’s current configuration; devices with no notable settings simply omit the field.
Example response
[
{
"device-id": "device-001",
"device-type": "LD03EWC0301",
"product-name": "Loop Delta",
"description": "Air quality monitor",
"location": 25,
"location-name": "Room 201",
"installation-time": "2024-01-15T10:30:00Z",
"path": "campus.building_a.floor_2.room_201",
"group-access": [
{
"group-id": 14,
"group-name": "Helsinki office",
"start-time": "2024-01-01T00:00:00Z",
"end-time": null
}
],
"notable-flags": [
{ "key": "notable.pressure.capillary", "tone": "info" },
{ "key": "notable.dense_interval", "tone": "warn", "params": { "interval": 120 } }
]
},
{
"device-id": "device-002",
"device-type": "L003EWC0301",
"product-name": "Loop Zero",
"description": "Temperature sensor",
"location": null,
"location-name": null,
"installation-time": null,
"path": null,
"group-access": []
}
]Response fields:
| Field | Type | Description |
|---|---|---|
device-id |
string | Unique device identifier |
device-type |
string | Product code |
product-name |
string | Human-readable product name resolved server-side from the first two characters of device-type (e.g. "Loop One", "Loop Delta"). null when the device-type prefix is not in the product catalog. |
description |
string | Human-readable description |
location |
integer | Current location ID (null if not installed, or if your organization doesn’t hold the device now). A device with a future removal-time is still considered installed. |
location-name |
string | Name of the current location (null when location is) |
installation-time |
datetime | When the current installation started (null if not installed, or if your organization doesn’t hold the device now) |
path |
string | Location path for current installation (null if not installed, or if your organization doesn’t hold the device now) |
group-access |
array | The periods during which the device is assigned to your group: past, current and future, newest first. Each entry has group-id, group-name, start-time and end-time (null = no end). Assignments to other groups are not shown. |
notable-flags |
array | Present only with include-notable-flags=true, and only for devices with at least one notable setting. See Notable device settings. |
Notable device settings DRAFT
notable-flags highlights device-configuration settings worth an operator’s attention — for example a Loop Delta measuring through a capillary tube, a denser-than-normal measurement interval, or power save switched off. The backend derives them from the device’s current configuration and returns only the localization key, a display tone, and any interpolation parameters — never a pre-rendered string — so every client localizes identically.
The field appears in three places: the device list above (with include-notable-flags=true), the location-level device object of last-quantities for everyone who sees the measurements except a share link, and the device configuration read for everyone who may read the configuration.
Each entry has:
| Field | Type | Description |
|---|---|---|
key |
string | Localization key identifying the setting; localize client-side. |
tone |
string | Display severity hint: info, warn, or notice. |
params |
object | Optional interpolation values for the localized message (omitted when none). |
Possible key values:
key |
Meaning | tone |
params |
|---|---|---|---|
notable.pressure.capillary |
Pressure-difference device measuring through a capillary tube (compensation algorithm engaged) | info |
— |
notable.pressure.free_flow |
Pressure-difference device measuring through a direct / free-flow tube | info |
— |
notable.dense_interval |
Measurement interval denser than 5 min while the device is not adaptively sending | warn |
interval (seconds) |
notable.power_save_off |
Device power save disabled | notice |
— |
The set of keys may grow over time; clients should tolerate unknown keys (fall back to the raw key or ignore the entry).
List install candidates DRAFT
| Method | Location |
|---|---|
| GET | device-inventory/install-candidates |
List the devices you can install: those your organization holds now that have no active installation, ordered by device-id. A device with a future removal-time is still installed and isn’t listed.
Required role: org-admin, or building-admin or operator at some location (see Get user grants)
Example response
[
{
"device-id": "device-002",
"device-type": "L003EWC0301",
"product-name": "Loop Zero",
"description": "Temperature sensor",
"location": null,
"location-name": null,
"installation-time": null,
"path": null
}
]The fields are those of List devices, without group-access and notable-flags. location, location-name, installation-time and path are always null.
Install device DRAFT
| Method | Location |
|---|---|
| POST | device/{device-id}/installation |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
| Body | Optional | Datatype | Description |
|---|---|---|---|
| location | no | Integer | Location ID to install at |
| installation-time | no | String (RFC3339) | When the installation starts |
| removal-time | yes | String (RFC3339) | When the installation ends (omit for open-ended) |
| end-current-installation? | yes | Boolean | If true and the device already has an open installation, atomically close it (its removal-time is set to the new installation-time) before creating the new installation. Returns 400 if the existing installation started after the new installation-time. |
Create a new installation record for a device at a location. Returns 400 if the device already has an installation that overlaps with the given time range, or if another device is already installed at the location during that time.
Required role: org-admin, or building-admin or operator at the location (see Get user grants)
Your organization must hold the device at installation-time, which may be in the past. Returns 403 if it didn’t.
If removal-time is omitted, the installation is open-ended (the device stays installed indefinitely until explicitly removed). If removal-time is provided, the installation is bounded to the period [installation-time, removal-time).
Pass end-current-installation?: true to move a device from its current room in one call. Moving a device that is installed now also needs one of the roles above where it is installed now. An install that doesn’t end a current installation is checked at the target location only.
Returns 404 for a device or location outside what you can see, and 403 for ones you can see but may not install or move there. Besides the devices installed at locations you see, you see your organization’s install candidates if you install devices at some location; an org-admin sees all of the organization’s devices.
Example request (open-ended)
{
"location": 25,
"installation-time": "2024-06-01T09:00:00Z"
}Example request (bounded)
{
"location": 25,
"installation-time": "2024-06-01T09:00:00Z",
"removal-time": "2024-09-01T08:00:00Z"
}Example request (move from current room)
{
"location": 30,
"installation-time": "2024-09-01T08:00:00Z",
"end-current-installation?": true
}Success response
{
"installation-id": 150,
"device-id": "device-001",
"location": 25,
"installation-time": "2024-06-01T09:00:00Z",
"removal-time": null
}Error response (overlap)
{
"error": "Device already has an installation that overlaps with this time. Remove the existing installation first."
}Remove device DRAFT
| Method | Location |
|---|---|
| POST | device/{device-id}/removal |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
| Body | Optional | Datatype | Description |
|---|---|---|---|
| removal-time | no | String (RFC3339) | When the device is removed |
Remove a device from its current location by setting removal-time on its active (open-ended) installation. Returns 404 for a device that doesn’t exist or is outside what you can see, 403 for one you can see but may not remove at that time, and 400 if the device is not currently installed anywhere.
Required role: org-admin, or building-admin or operator at the device’s current location (see Get user grants)
The removal-time can be in the past or the future. A future removal-time schedules the removal — the device remains “currently installed” until that time passes.
Example request
{
"removal-time": "2024-06-15T16:00:00Z"
}Success response
{
"installation-id": 150,
"device-id": "device-001",
"location": 25,
"installation-time": "2024-06-01T09:00:00Z",
"removal-time": "2024-06-15T16:00:00Z"
}The response is the updated installation record with removal-time now set.
List installations DRAFT
| Method | Location |
|---|---|
| GET | installations |
List the installation records that started while your organization held the device, ordered by installation time (newest first).
Required role: org-admin, or building-admin, operator or device-configurator at some location (see Get user grants)
If you install or configure devices at some locations only, the list has only the installations at those locations or below them.
Example response
[
{
"installation-id": 152,
"device-id": "device-001",
"location": 30,
"installation-time": "2024-09-01T08:00:00Z",
"removal-time": null
},
{
"installation-id": 151,
"device-id": "device-001",
"location": 30,
"installation-time": "2024-03-15T14:00:00Z",
"removal-time": "2024-06-01T10:00:00Z"
},
{
"installation-id": 150,
"device-id": "device-001",
"location": 25,
"installation-time": "2024-01-01T09:00:00Z",
"removal-time": "2024-03-15T14:00:00Z"
}
]Response fields:
| Field | Type | Description |
|---|---|---|
installation-id |
integer | Unique installation record ID |
device-id |
string | Device identifier |
location |
integer | Location ID |
installation-time |
datetime | When the installation started |
removal-time |
datetime | When the installation ended (null = still installed or scheduled) |
List device installations DRAFT
| Method | Location |
|---|---|
| GET | device/{device-id}/installations |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
List all installation records for a specific device, ordered by installation time (newest first). Includes the location path for each installation.
Required role: org-admin, or building-admin, operator or device-configurator at some location (see Get user grants)
If you install or configure devices at some locations only, the list has only the device’s installations at those locations or below them.
Example response
[
{
"installation-id": 152,
"device-id": "device-001",
"location": 30,
"installation-time": "2024-09-01T08:00:00Z",
"removal-time": null,
"path": "campus.building_a.floor_2.room_201"
},
{
"installation-id": 150,
"device-id": "device-001",
"location": 25,
"installation-time": "2024-01-01T09:00:00Z",
"removal-time": "2024-03-15T14:00:00Z",
"path": "campus.building_a.floor_1.room_101"
}
]Update installation DRAFT
| Method | Location |
|---|---|
| PUT | installations/{installation-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| installation-id | no | path | Integer |
| Body | Optional | Datatype | Description |
|---|---|---|---|
| installation-time | yes | String (RFC3339) | New start time |
| removal-time | yes | String (RFC3339) or null | New end time (null to clear, making the installation open-ended) |
Update the times on an existing installation record. Only include fields you want to change. Returns 400 if the update would cause an overlap with another installation (same device at two locations, or two devices at the same location). Returns 403 if a new installation-time falls before your organization had the device.
Required role: org-admin, or building-admin or operator at the installation’s location (see Get user grants)
Example request — set removal time
{
"removal-time": "2024-12-31T23:59:59Z"
}Example request — clear removal time (make open-ended)
{
"removal-time": null
}Success response
Returns the updated installation record.
{
"installation-id": 150,
"device-id": "device-001",
"location": 25,
"installation-time": "2024-06-01T09:00:00Z",
"removal-time": "2024-12-31T23:59:59Z"
}Delete installation DRAFT
| Method | Location |
|---|---|
| DELETE | installations/{installation-id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| installation-id | no | path | Integer |
Delete an installation record entirely. After deletion, it is as if the device was never installed at that location during that time period. Measurement data is not affected — only the installation record is removed.
Required role: org-admin, or building-admin or operator at the installation’s location (see Get user grants)
Returns 200 with the deleted record on success.
Survey Management APIs DRAFT
These APIs manage feedback surveys — view which surveys are assigned to which locations, view results, and manage assignments.
List survey summaries DRAFT
| Method | Location |
|---|---|
| GET | survey2/summary |
List all surveys with summary information.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
[
{
"id": 42,
"page-count": 3,
"question-count": 8,
"first-page-title": {
"en": "Indoor Air Quality Feedback",
"fi": "Sisäilmapalaute"
}
}
]List survey-location assignments DRAFT
| Method | Location |
|---|---|
| GET | survey2/locations |
List which surveys are assigned to the locations you can view. Archived locations are excluded.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
[
{
"survey": 42,
"location": 15,
"location-name": "Meeting Room",
"location-path": "campus.office.floor_1.room_101",
"datetime": "2024-01-15T10:30:00Z"
}
]Assign survey to location DRAFT
| Method | Location |
|---|---|
| PUT | survey2/location/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
| Body | Optional | Datatype |
|---|---|---|
| survey-id | no | Integer |
Assign a survey to a location, enabling anonymous feedback collection at that location.
Required role: org-admin, or building-admin at the location or above it
Example request
{
"survey-id": 42
}Remove survey from location DRAFT
| Method | Location |
|---|---|
| DELETE | survey2/location/{id} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| id | no | path | Integer (location ID) |
Remove a survey assignment from a location. Returns 204 on success.
Required role: org-admin, or building-admin at the location or above it
Get survey results DRAFT
| Method | Location |
|---|---|
| GET | survey2/results |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| start | no | query | String (RFC3339) |
| end | no | query | String (RFC3339) |
Get all survey responses within the given time range, across all locations where you manage surveys. Archived locations are excluded. Results include both choice and free-text responses.
Required role: org-admin, or building-admin at some location (see Get user grants)
Example response
[
{
"datetime": "2024-01-15T10:30:00Z",
"page-title": {"en": "Indoor Air Quality", "fi": "Sisäilma"},
"question-text": {"en": "How is the temperature?", "fi": "Miten koet lämpötilan?"},
"response-type": "choice",
"location": 15,
"path": "campus.office.floor_1.room_101",
"choice-text": {"en": "Good", "fi": "Hyvä"}
},
{
"datetime": "2024-01-15T10:35:00Z",
"page-title": {"en": "Other Feedback", "fi": "Muu palaute"},
"question-text": {"en": "Other comments?", "fi": "Muuta kommentoitavaa?"},
"response-type": "free",
"location": 15,
"path": "campus.office.floor_1.room_101",
"text-response": "The AC is too loud in the afternoon"
}
]For response-type: "choice", the response includes choice-text. For response-type: "free", it includes text-response.
LWM2M Device Configuration APIs DRAFT
LWM2M (Lightweight M2M) is the protocol used for device management. These APIs allow viewing device status and configuration.
Device configuration format
Device configuration is a JSON object stored as-is in the database and passed to the device firmware. The same format is used when reading (GET) and writing (PUT) device configuration.
Configuration fields:
| Field | Type | Description |
|---|---|---|
e |
integer | Enable bits bitmask (0–2147483647). Controls which sensors and features are active. See enable bit positions below. |
i |
integer | Measurement interval in seconds (10–86400). How often the device takes measurements. |
si |
integer | Particle measurement interval in seconds (10–86400). Separate interval for particle sensor measurements. |
md |
integer | Microphone duty cycle percentage (0–100). |
c |
string | Command to execute (optional). See command strings below. The device processes the command once; it is not automatically cleared from the stored configuration, and the device re-runs it whenever the configuration version is bumped. A client that reads a configuration, modifies it and writes it back must therefore clear c itself unless it intends the command to run again. |
ct |
integer | Calibration target (optional). Used with the cco2 command to specify the reference CO2 ppm value. |
cal |
array | Software calibration data (optional). An array of per-quantity calibration entries — see Calibration data format below. Managed by the calibration UI. Omit only if the device has never been calibrated; once cal has appeared in any earlier config version, every subsequent config should round-trip it unchanged. To explicitly clear all calibrations, send "cal": []. Treat the contents as opaque: clients should round-trip the value unchanged unless they are the calibration UI itself. |
Example configuration:
{
"e": 2147483391,
"i": 300,
"si": 900,
"md": 10,
"cal": [
{"q": 30, "p": [[0.0, 0.0], [100.0, 99.5]]},
{"q": 4, "p": [[400.0, 410.0], [1000.0, 998.0]]}
]
}Calibration data format
The cal field is a JSON array. Each element is an object describing the calibration of a single measurement quantity:
| Field | Type | Description |
|---|---|---|
q |
integer | Quantity being calibrated, as the device’s measurement resource ID (e.g. 4 = co2, 30 = pressure_diff, 36 = pn0p5). |
p |
array | Calibration points, each a [raw, calibrated] pair sorted by raw ascending. |
Example cal value:
[
{"q": 30, "p": [[0.0, 0.0], [100.0, 99.5]]},
{"q": 4, "p": [[400.0, 410.0], [1000.0, 998.0]]}
]Calibratable quantities:
q |
Quantity | q |
Quantity |
|---|---|---|---|
| 1 | temperature |
27 | tvoc_density |
| 2 | humidity |
30 | pressure_diff |
| 3 | tvoc |
31 | pressure_diff_min |
| 4 | co2 |
32 | pressure_diff_max |
| 5 | pm1p0 |
33 | co |
| 6 | pm2p5 |
36 | pn0p5 |
| 7 | pm4p0 |
37 | pn1p0 |
| 8 | pm10p0 |
38 | pn2p5 |
| 9 | pressure |
39 | pn4p0 |
| 10 | light |
40 | pn0p3 |
| 150 | temperature_1 |
Limits: at most 12 entries and 5 points per entry. Devices with older firmware apply only the first 3 entries. The whole configuration must fit in 990 bytes, see Write device configuration.
Setting and clearing calibration on the device:
| Wire form on PUT | Effect on the device |
|---|---|
cal field omitted entirely |
Device keeps whatever calibration it currently has (historical firmware behaviour). |
"cal": [] (empty array) |
Device clears all software calibrations. |
"cal": [ ... ] (non-empty) |
Device replaces all software calibrations with the supplied entries. There is no per-quantity merge; entries you do not include are dropped. |
Note that only the third row replaces calibrations on the device. Omitting cal does not clear it — but it does cause the stored config in the server’s history to no longer reflect what the device is actually running, which is misleading and easy to mishandle in any subsequent read-modify-write. Treat omission as “I have never seen cal in any prior version of this device’s config”; if you have seen it, send it back.
Note for client implementors. Because
calis opaque passthrough data and its internal shape may evolve, clients that are not the calibration UI should deserialize it as a generic JSON value (e.g.JsonElement/any/serde_json::Value) rather than a strongly-typed model. Using a strict object type will fail to deserialize the array and — if the failure path falls back to writing a default config that includes"cal": []— silently destroy the device’s calibration. See the warning under Write device configuration.
Enable bit positions
The e field is a 31-bit integer bitmask. Each bit controls one sensor or feature. A bit value of 1 means enabled, except for bits marked as inverted logic where 1 means disabled/OFF.
The Schema key column shows the corresponding key in the config-schema endpoint’s enable_bits response. Use the config-schema to determine which bits a product type supports before setting them.
| Bit | Schema key | Description | Notes |
|---|---|---|---|
| 0 | microphone |
Microphone sensor | |
| 1 | particles |
Particulate matter sensor | |
| 2 | gps |
GPS | Inverted logic — bit 1 = GPS OFF |
| 3 | temperature |
Temperature sensor | |
| 4 | humidity |
Humidity sensor | |
| 5 | co2 |
CO2 sensor | |
| 6 | tvoc |
TVOC PPB sensor | |
| 7 | light |
Light sensor | |
| 8 | pressure |
Pressure sensor | |
| 9 | motion |
Motion sensor | |
| 10 | battery |
Battery monitoring | |
| 11 | extra_temp |
External temperature probe | |
| 12 | debug_stats |
Debug statistics | Deprecated |
| 13 | nw_stats |
Network statistics (RSRP, SNR) | Deprecated |
| 14 | pressure_diff_low_power |
Differential pressure — low power mode | |
| 15 | pressure_diff_accurate |
Differential pressure — accurate mode | |
| 16 | pressure_diff_free_flow |
Differential pressure — free flow pipe | |
| 17 | datalogger |
Store measurements offline, send when network available | |
| 18 | network_backoff |
24h backoff when network unavailable (only if datalogger disabled) | |
| 19 | co2_abc |
CO2 automatic baseline calibration (toward outdoor CO2 level) | |
| 20 | tvoc_index_off |
TVOC index sensor (VOC Index 1–500) | Inverted logic — bit 1 = TVOC index OFF |
| 21 | spl_pause_during_fan |
Pause sound level measurement while the particle sensor fan runs | Defaults to 1 (enabled) |
| 22 | particle_counts_off |
Particle counts (pn0p5, pn1p0, pn2p5, pn4p0, pn0p3) and typical particle size (tps) |
Inverted logic — bit 1 = particle counts OFF. Needs bit 1. |
| 23–26 | — | Unused | |
| 27 | tvoc_debug_not_active |
TVOC debug | Inverted logic — bit 1 = OFF. Deprecated |
| 28 | zero_adaptive_sending |
Adaptive sending (Zero hardware only) | |
| 29 | power_save |
Power saving when not on USB power | |
| 30 | default_cat_m |
Prefer Cat-M over NB-IoT | Requires reboot |
About bit 21 (
spl_pause_during_fan). The particle sensor fan sits a few centimetres from the microphone, so sound level measured while the fan spins reports the device’s own fan noise rather than the room. With the bit set, the device measures sound level only while the fan is stopped. Devices report lower and more accurate sound levels than before, which appears as a step change in historical trends rather than a fault. When the measurement interval is short enough that the fan runs continuously (below 65 s, or below 20 s in power save), no sound level is reported at all for as long as that configuration lasts — this is intended behaviour and the device raises no problem for it. The bit is set by default; clear it only if you specifically want the old behaviour.
Default value: 2147483647 (0x7FFFFFFF — all 31 bits set to 1). This enables all sensors and features, with inverted-logic bits in their OFF state.
Example: To enable only microphone (bit 0), temperature (bit 3), and humidity (bit 4), keep all inverted-logic bits set (GPS, TVOC index, particle counts and TVOC debug off), and keep the fan pause enabled (bit 21):
e = bit 0 + bit 3 + bit 4 + bit 2 + bit 20 + bit 21 + bit 22 + bit 27 = 1 + 8 + 16 + 4 + 1048576 + 2097152 + 4194304 + 134217728 = 141557789
When building e from scratch like this, remember the bits that are features rather than sensors: leaving bit 21 clear silently restores the old behaviour of measuring sound level through the fan noise.
Command strings
The c field accepts the following command strings. The Schema key column shows the corresponding key in the config-schema endpoint’s commands response.
| Wire value | Schema key | Description |
|---|---|---|
"r" |
reboot |
Reboot device |
"b" |
locate_sound |
Play the locate sound so the device can be found. The device plays a rising chime for about a minute, starting at its next check-in rather than immediately; moving or shaking the device silences it. A device below approximately 3.1 V battery ignores the command, with no feedback. Sound level measurement is suspended while the sound plays, so the measurement cycle that overlaps it reports no noise values. |
"cco2" |
calibrate_co2 |
Calibrate CO2 sensor. Set ct to the reference ppm value. |
"cr" |
reset_config |
Reset to default configuration (deprecated). Other config changes in the same write are ignored. |
"pd" |
post_debug |
Post debug data (deprecated) |
"rt" |
reset_tvoc |
Reset TVOC sensor (deprecated) |
List LWM2M devices DRAFT
| Method | Location |
|---|---|
| GET | lwm2m/device-v2 |
List all LWM2M devices with their latest configuration and firmware information. A building-admin, operator or device-configurator without org-admin gets the devices their organization holds now that are installed now at their locations, with all fields.
Required role: org-admin, or building-admin, operator or device-configurator at some location (see Get user grants)
Example response
[
{
"id": 42,
"endpoint": "123456789012345",
"device-id": "123456789012345",
"hardware": "L103EWC0301",
"config-version": 5,
"config-datetime": "2024-06-01T10:30:00Z",
"config": { "e": 2147483391, "i": 300, "si": 900, "md": 10 },
"comment": "Updated measurement interval",
"firmware-version": "2.0.0",
"firmware-semver": "2.0.0",
"firmware-description": "Adds CO2 ABC support",
"target-hw": "L103EWC0301",
"firmware-uri": "https://firmware.example.com/L103EWC0301/app-2.0.0.bin",
"firmware-datetime": "2024-06-02T09:15:00Z",
"firmware-status": "confirmed",
"firmware-error": null
}
]Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | Internal database ID |
endpoint |
string | Device endpoint identifier |
device-id |
string | Same as endpoint |
hardware |
string | Hardware/product code |
config-version |
integer | null | Current configuration version (null if no configuration has been written) |
config-datetime |
datetime | null | When the current configuration was written |
config |
object | null | The current configuration itself, in the Device configuration format |
comment |
string | null | Comment supplied with the current config version |
firmware-version |
string | null | Version label of the firmware currently associated with the device |
firmware-semver |
string | null | Semantic version of the current firmware |
firmware-description |
string | null | Description / changelog of the current firmware |
target-hw |
string | null | Hardware target of the current firmware (matches the device’s hardware field when an assignment exists) |
firmware-uri |
string | null | URI of the firmware binary |
firmware-datetime |
datetime | null | Timestamp of the latest firmware status row for this device |
firmware-status |
string | null | One of configured (assigned, awaiting install), confirmed (installed successfully), or error (install failed) |
firmware-error |
integer | null | Device-side error code from the last firmware status report (populated only when firmware-status is "error") |
Visibility note. An
org-admingets every device their organization holds now, held in the past or is due to hold; for a device the organization doesn’t hold now, theconfig-*,config,comment,target-hw, and allfirmware-*fields are omitted from that device’s row entirely (not set to null). Clients should treat all of these fields as both nullable and optional.
Get device configuration DRAFT
| Method | Location |
|---|---|
| GET | lwm2m/device/endpoint/{endpoint}/config |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| endpoint | no | path | String |
Get the current configuration for a specific device. The response wraps the device configuration in an envelope with metadata.
Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)
Response fields:
| Field | Type | Description |
|---|---|---|
version |
integer | Configuration version, incremented on every write |
config |
object | The device configuration itself, in the Device configuration format |
comment |
string | null | Comment supplied with this config version (see write endpoint for guidance) |
datetime |
datetime | When this version was written |
notable-flags |
array | Present for every caller who may read the configuration, and only when the device has at least one notable setting. See Notable device settings. |
Example response
{
"version": 5,
"config": {
"e": 2147483391,
"i": 120,
"si": 900,
"md": 10
},
"comment": "Denser measurement interval",
"datetime": "2024-06-01T10:30:00Z",
"notable-flags": [
{ "key": "notable.dense_interval", "tone": "warn", "params": { "interval": 120 } }
]
}Get configuration history DRAFT
| Method | Location |
|---|---|
| GET | lwm2m/device/endpoint/{endpoint}/config/history |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| endpoint | no | path | String |
Get the configuration change history for a device. Returns a list of configuration versions, newest first, in the same envelope shape as Get device configuration.
Required role: org-admin
Example response
[
{
"version": 5,
"config": { "e": 2147483391, "i": 300, "si": 900, "md": 10 },
"comment": "Increased measurement interval",
"datetime": "2024-06-01T10:30:00Z"
},
{
"version": 4,
"config": { "e": 2147483391, "i": 60, "si": 900, "md": 10 },
"comment": "Initial configuration",
"datetime": "2024-05-15T08:00:00Z"
}
]Write device configuration DRAFT
| Method | Location |
|---|---|
| PUT | lwm2m/device/endpoint/{endpoint}/config |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| endpoint | no | path | String |
Write a new configuration version for a device. The device will receive the new config on its next check-in. The version number is assigned by the server and returned in the response — clients must not supply it.
Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)
Warning — read-modify-write. The submitted
configobject replaces the stored configuration in full; there is no field-level merge on the server. Any field you omit is dropped from the next stored version. Always GET the current config, modify the fields you want to change, and PUT the complete object back.Special handling for
cal(software calibration).calhas its own on-device semantics, summarised in the table under Calibration data format:
- Omitting
caldoes not clear the device’s calibration — the firmware keeps whatever it had — but it desynchronises the server’s stored config from the device, and any later read-modify-write cycle will compound the confusion. Ifcalhas appeared in any prior version for a device, every subsequent PUT for that device should include it."cal": []is how you explicitly clear all calibrations on the device. Do not send this unless that is what you mean."cal": [ ... ]with any entries replaces all calibrations on the device with the supplied entries (no per-quantity merge).A common way to silently destroy calibration: deserializing the GET response into a strongly-typed model that declares
calas the wrong JSON type (e.g. an object when it is actually an array). Deserialization fails, the client falls back to writing a “default” config that includes"cal": [], and the device’s calibration is wiped. Treatcalas opaque (JsonElement/any/serde_json::Value) so the GET response always parses successfully andcalround-trips unchanged on PUT.
Request body fields:
| Field | Type | Required | Description |
|---|---|---|---|
config |
object | yes | The device configuration in the Device configuration format. Stored as-is — see warning above about preserving fields like cal. |
comment |
string | null | no | Free-text comment describing this change. Recommended to either uniquely describe the configuration (e.g. what changed and why) or leave it out entirely — generic repeated comments add noise to the history without adding information. |
expected-version |
integer | null | no | Optimistic-concurrency guard. When given, the write is rejected with 409 unless it matches the device’s current config version at write time (checked atomically, not read-then-write) — use this to detect “someone else changed this device’s config since I last read it” instead of silently overwriting a concurrent change. 0 means “no config has been written yet”. Omit or send null to skip the check and write unconditionally (the pre-existing behavior). |
config.i is validated against the device’s config-schema interval.min/interval.max when a schema is configured for the device’s product type; a value outside that range is rejected with 400. Devices with no config-schema on record are unaffected (permissive, matching pre-0.32.0 behavior).
config must fit in 990 bytes as the device receives it: compact JSON in UTF-8, with numbers as the server stores them (2.0E7 counts as 20000000). A longer config is rejected with 400 and not stored.
Example request body
{
"config": {
"e": 2147483391,
"i": 300,
"si": 900,
"md": 10
},
"comment": "Increased measurement interval to reduce battery drain"
}Example response
{
"version": 6
}The returned version is the version number assigned to the newly written configuration.
Error responses:
| Status | Body | Description |
|---|---|---|
| 400 | Forbidden | User does not have access to this device |
| 400 | {"error": "...", "field": "..."} |
config.i (or si/md, where applicable) is outside the range declared by the device’s config-schema |
| 400 | {"error": "...", "field": "config"} |
config is longer than 990 bytes |
| 409 | {"error": "Config version conflict", "current-version": integer} |
expected-version was given and didn’t match the current version — re-read the config and retry |
Device Live View DRAFT
Temporarily drops a device’s measurement interval to its config-schema-declared minimum for near-real-time monitoring (e.g. watching a live differential-pressure reading while adjusting an HVAC system), then automatically reverts the device back to its prior configuration after a bounded time.
Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)
Ground truth that shapes this feature: devices use PSM (Power Save Mode), not eDRX — there is no way to wake a device from the server side. A config change, whether starting the live session or reverting it, only takes effect at the device’s next self-initiated check-in. There is no way to force this from software; the fastest way to get a device to pick up a change immediately is to physically power-cycle it (slide its power switch off, then on). If the device loses power or coverage while a session is active, the server-side revert is written immediately, but the device won’t actually apply it until it reconnects — possibly much later. This is an accepted limitation, not a bug to report.
A live-view session is a single shared resource per device — there is no per-viewer/participant tracking. Whoever calls start while a session is already active for that device just gets the existing session back; any caller with access to the device may heartbeat or stop it, regardless of who started it. Use the started-by-me response field if the client wants to distinguish “I started this” from “someone else did”.
A session ends one of three ways:
- Manually, via
stop. - Idle timeout — no
heartbeatcall for ~3 minutes. - Hard deadline — ~2 hours after
start, regardless of heartbeats. This is the only guard against a session being kept alive indefinitely by a well-behaved client.
Idle timeout and the background sweep’s own cadence (~5 minutes) compound, so an abandoned session can take up to ~8 minutes to actually revert — expected, not a bug.
Start a live-view session DRAFT
| Method | Location |
|---|---|
| POST | lwm2m/device/endpoint/{endpoint}/live-view/start |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| endpoint | no | path | String |
Starts a new live-view session, writing a new config version with i set to the device’s schema-declared minimum interval. If a session is already active for this device, returns that session unchanged — no new session or config write.
Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | Session id, used in the heartbeat/stop paths below |
device-id |
string | The device’s endpoint |
status |
string | active, reverted, or superseded |
prior-config-version |
integer | The config version in effect before this session started; restored on revert |
live-config-version |
integer | The config version this session wrote |
started-at |
datetime | |
expires-at |
datetime | Idle deadline — extended by each heartbeat call |
hard-deadline-at |
datetime | started-at + max session duration; never extended |
resolved-at |
datetime | null | When the session ended, or null while active |
started-by-me |
boolean | Whether the calling user is the one who started this session |
live-interval-seconds |
integer | null | The i value in effect while this session is active |
next-check-in-eta |
datetime | null | Best-effort estimate of when the device will next check in and pick up the pending config. Not guaranteed — there is no true LWM2M registration timestamp available server-side, this is derived from the device’s last observed activity. Treat it purely as a passive-fallback hint for a “this activates automatically within about n minutes” message, not a promise. |
Example response
{
"id": 42,
"device-id": "abc123",
"status": "active",
"prior-config-version": 5,
"live-config-version": 6,
"started-at": "2026-08-10T09:00:00Z",
"expires-at": "2026-08-10T09:03:00Z",
"hard-deadline-at": "2026-08-10T11:00:00Z",
"resolved-at": null,
"started-by-me": true,
"live-interval-seconds": 10,
"next-check-in-eta": "2026-08-10T09:05:00Z"
}Error responses:
| Status | Body | Description |
|---|---|---|
| 400 | Forbidden | User does not have access to this device |
| 409 | {"error": "..."} |
Device is lost/destroyed (lifecycle-blocked), has no config-schema interval floor configured, has no existing config to branch off of, or the config write failed — retry |
Heartbeat a live-view session DRAFT
| Method | Location |
|---|---|
| POST | lwm2m/device/endpoint/{endpoint}/live-view/{session-id}/heartbeat |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| endpoint | no | path | String |
| session-id | no | path | Integer |
Renews the session’s idle deadline (expires-at). Clients should call this roughly every 60 seconds while the live-view screen is open.
Response: the session, in the same shape as Start a live-view session.
Error responses:
| Status | Body | Description |
|---|---|---|
| 404 | Not found | No such session, or the session belongs to a different device than endpoint |
| 409 | {"error": "...", "status": "..."} |
The session is no longer active (already reverted or superseded) — status gives its actual current state |
Stop a live-view session DRAFT
| Method | Location |
|---|---|
| POST | lwm2m/device/endpoint/{endpoint}/live-view/{session-id}/stop |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| endpoint | no | path | String |
| session-id | no | path | Integer |
Ends the session and immediately reverts the config back to prior-config-version (doesn’t wait for the background sweep). If a concurrent config write happened to this device while the session was active, the session is marked superseded instead — the revert is skipped so the other write is never clobbered. Calling stop on an already-resolved session is idempotent and just returns its current state.
Response: the session, in the same shape as Start a live-view session.
Error responses:
| Status | Body | Description |
|---|---|---|
| 404 | Not found | No such session, or the session belongs to a different device than endpoint |
Get live-view session status DRAFT
| Method | Location |
|---|---|
| GET | lwm2m/device/endpoint/{endpoint}/live-view |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| endpoint | no | path | String |
Returns the device’s most recent live-view session, active or resolved — so a client can detect a superseded outcome even after the session has ended (e.g. on reopening the live-view screen).
Response: the session, in the same shape as Start a live-view session.
Error responses:
| Status | Body | Description |
|---|---|---|
| 404 | Not found | The device has never had a live-view session |
Get device config schema DRAFT
| Method | Location |
|---|---|
| GET | device/{device-id}/config-schema |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
Get the configuration schema for a device based on its product type. The schema describes which configuration options the device supports, including sensor enable/disable toggles, measurement interval ranges, and available commands.
The product type is derived from the first 2 characters of the device’s product code (e.g., a device with code L103EWC0301 maps to product type L1).
Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)
Response: The config-schema JSON object, or null if the product type exists but has no schema configured.
A 200 response with null body means the device and its product type were found, but no configuration schema has been set for that product type yet. A 404 means the device or its product type was not found.
The schema is per product type, not tailored to the specific device’s firmware or hardware generation. Constraint fields like min_gen and min_ver are provided so that clients can determine which options are available on a particular device by comparing against the device’s product code and firmware version.
Keys use snake_case naming (e.g. extra_temp, pressure_diff_low_power). The keys in enable_bits and commands correspond to the schema key columns in the enable bit positions and command strings tables in the Device Configuration Format section above.
The schema is exhaustive — every known enable bit and command is always present in the response with at least a supported field. There are no implicit defaults; if an option is not relevant to the product type, it is explicitly listed as "supported": false.
Each enable-bit entry also carries its bit position, UI category, and behavior flags (inverted_logic, requires_reboot, requires_confirmation) in-band, so clients can build configuration UIs directly from the schema response without consulting the enable bit positions table. That table remains the wire-format reference for the raw e bitmask.
Example response (200) — product type L1 (Loop One):
{
"enable_bits": {
"microphone": {"position": 0, "category": "measurement", "supported": true},
"particles": {"position": 1, "category": "measurement", "supported": true},
"gps": {"position": 2, "category": "position", "supported": true, "inverted_logic": true, "requires_reboot": true},
"temperature": {"position": 3, "category": "measurement", "supported": true, "min_gen": "03"},
"humidity": {"position": 4, "category": "measurement", "supported": true, "min_gen": "03"},
"co2": {"position": 5, "category": "measurement", "supported": true, "min_gen": "03"},
"tvoc": {"position": 6, "category": "measurement", "supported": true, "min_gen": "03"},
"light": {"position": 7, "category": "measurement", "supported": true, "min_gen": "03"},
"pressure": {"position": 8, "category": "measurement", "supported": true, "min_gen": "03"},
"motion": {"position": 9, "category": "measurement", "supported": true, "min_gen": "03"},
"battery": {"position": 10, "category": "measurement", "supported": true, "min_gen": "03"},
"extra_temp": {"position": 11, "category": "measurement", "supported": false},
"debug_stats": {"position": 12, "category": "debug", "supported": true, "deprecated": true, "min_gen": "03"},
"nw_stats": {"position": 13, "category": "debug", "supported": true, "deprecated": true, "min_gen": "03"},
"pressure_diff_low_power": {"position": 14, "category": "measurement", "supported": false},
"pressure_diff_accurate": {"position": 15, "category": "measurement", "supported": false},
"pressure_diff_free_flow": {"position": 16, "category": "measurement", "supported": false},
"datalogger": {"position": 17, "category": "connectivity", "supported": true, "min_gen": "03"},
"network_backoff": {"position": 18, "category": "connectivity", "supported": true, "min_gen": "03"},
"co2_abc": {"position": 19, "category": "measurement", "supported": true, "min_gen": "03"},
"tvoc_index_off": {"position": 20, "category": "measurement", "supported": true, "inverted_logic": true, "min_gen": "03"},
"tvoc_debug_not_active": {"position": 27, "category": "debug", "supported": true, "inverted_logic": true, "deprecated": true},
"zero_adaptive_sending": {"position": 28, "category": "power", "supported": false},
"power_save": {"position": 29, "category": "power", "supported": true},
"default_cat_m": {"position": 30, "category": "connectivity", "supported": true, "requires_reboot": true, "requires_confirmation": true}
},
"interval": {"min": 10, "max": 86400},
"particle_interval": {"supported": true, "min": 10, "max": 86400},
"mic_duty_cycle": {"supported": true, "min": 0, "max": 100},
"commands": {
"reboot": {"supported": true},
"locate_sound": {"supported": true, "min_gen": "03"},
"calibrate_co2": {"supported": true, "min_gen": "03"},
"reset_config": {"supported": true, "deprecated": true},
"post_debug": {"supported": true, "deprecated": true},
"reset_tvoc": {"supported": true, "deprecated": true}
},
"power_profile": {
"min_gen": "03",
"min_fw": null,
"max_fw": null,
"measurement_basis": {
"source": "Spartalainen_virrankulutus.xlsx; modem re-measured 2026-05-18",
"measured_at": "2024-03-04",
"fw_version": "2.1.0"
}
},
"power_profile_reason": null,
"battery": {
"code": "03",
"description": "Li-Ion 2x4900 mAh",
"capacity_mah": 9800,
"nominal_voltage_v": 3.7
},
"battery_reason": null
}Each entry in enable_bits and commands contains at minimum a supported boolean. Additional fields may be present:
| Field | Type | Description |
|---|---|---|
position |
integer | Bit position (0–31) in the e bitmask. Present on enable_bits only; commands have no position. |
category |
string | UI grouping. One of measurement, position, connectivity, power, debug. Present on enable_bits only. |
supported |
boolean | Whether the device hardware/software supports this option. |
inverted_logic |
boolean | If true, setting the bit disables the feature. Default false. |
requires_reboot |
boolean | If true, the device must reboot for the change to take effect. Default false. |
requires_confirmation |
boolean | If true, UIs should ask the user to confirm before disabling/changing this option. Default false. |
globally_deprecated |
boolean | If true, the option is deprecated across all product types. Default false. |
deprecated |
boolean | If true, the option is deprecated for this specific product type and should not be used in new integrations. |
min_ver |
string | Minimum firmware version required (semver, e.g. "2.1.0"). Compare against the device’s firmware version. |
min_gen |
string | Minimum hardware generation required (e.g. "03" for Gen 2). Compare against positions 2–3 of the device’s product code. |
position, category, inverted_logic, requires_reboot, requires_confirmation, and globally_deprecated are intrinsic to the bit/command and identical across all product types. The remaining fields are per-product overrides.
Battery & power profile fields — the response also carries four additive top-level keys that the battery-life estimator consumes. Clients that don’t need an estimate can ignore them.
| Field | Type | Description |
|---|---|---|
power_profile |
object | null | The selected power profile entry for the device’s hardware generation and firmware version. null when no profile applies (see power_profile_reason). Fields: min_gen, min_fw, max_fw (the selection constraints), and measurement_basis (free-form provenance: source, measured_at, fw_version, notes). |
power_profile_reason |
string | null | null when a profile matched. Otherwise one of no_profile_for_device (no profile entry covers this device’s hardware generation; typical for Gen-1 devices in v1), no_firmware_version (the device has not reported a firmware version yet), fw_out_of_range (a profile exists but the device’s firmware version falls outside its min_fw/max_fw window — the estimator still returns a result, with a warning). |
battery |
object | null | The device’s battery hardware, resolved from positions 8–9 of the product code. Fields: code (2-char), description, capacity_mah, nominal_voltage_v, and external_power: true when the device is mains-powered (otherwise omitted). null when the battery code can’t be resolved (see battery_reason). The full catalog is available via Get battery codes. |
battery_reason |
string | null | null when the battery resolves to a battery-backed entry. mains_powered for mains devices, unknown_battery_code when the code is not present in the catalog, malformed_device_type when the device code is too short or doesn’t have alphanumerics in positions 8–9. |
Error responses:
| Status | Body | Description |
|---|---|---|
| 403 | Forbidden | User does not have access to this device |
| 404 | Not found | Device or product type not found |
Get device default config DRAFT
| Method | Location |
|---|---|
| GET | device/{device-id}/default-config |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
Get the default device configuration for a device based on its product type. The defaults can be used to pre-populate a configuration form, or as a known starting point when resetting a device to factory defaults.
The product type is derived from the first 2 characters of the device’s product code (e.g., a device with code L103EWC0301 maps to product type L1). The returned object follows the Device configuration format — see that section for the meaning of each field and the bit positions inside e.
Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)
Example response (200) — product type L1 (Loop One):
{
"e": 2147483647,
"i": 600,
"si": 3600,
"md": 50
}Error responses:
| Status | Body | Description |
|---|---|---|
| 403 | Forbidden | User does not have access to this device |
| 404 | Not found | Device or product type not found, or no default config has been set for the product type |
Estimate device battery life DRAFT
| Method | Location |
|---|---|
| POST | device/{device-id}/usage-estimate |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | no | path | String |
Estimate the average current draw and resulting battery life for a proposed device configuration. The estimator combines:
- The device’s hardware generation and live firmware version
- The matching power profile published in the config-schema response
- The battery hardware resolved from the device’s product code
- The configuration body the caller supplies (typically the in-progress edit shown in a config UI)
The endpoint is read-only and idempotent — it is safe to debounce and re-issue on every keystroke in a config form.
Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)
Request body
{
"config": {
"e": 2147483647,
"i": 600,
"si": 3600,
"md": 50
}
}The config object follows the Device configuration format. Validation rules:
| Field | Required when |
|---|---|
e |
always |
i |
always; must fall inside interval.min/interval.max from the config-schema |
si |
only when the config-schema declares particle_interval.supported: true; must fall inside its declared range |
md |
only when the config-schema declares mic_duty_cycle.supported: true; must fall inside its declared range |
Unknown keys inside config (and unknown top-level keys in the request body) are accepted for forward compatibility. e bits set for features the product type does not support are accepted without error — the device firmware ignores bits for absent hardware.
Response shape
The response always contains battery, battery_reason, power_profile, power_profile_reason, and estimators (which is either an object with a battery estimate, or null when no estimate could be produced). Mains-powered devices additionally carry mains_powered: true. Unresolved battery codes additionally carry unknown_battery_code set to the offending 2-character code.
| Field | Type | Description |
|---|---|---|
battery |
object | null | Same shape as the battery field on the config-schema response. null when the battery code is unknown or the device type is malformed. |
battery_reason |
string | null | null on success, otherwise mains_powered / unknown_battery_code / malformed_device_type. |
unknown_battery_code |
string | Present only when battery_reason = unknown_battery_code. The 2-character code that could not be resolved. |
mains_powered |
boolean | Present and true only when the device is mains-powered. |
power_profile |
object | null | Same shape as the power_profile field on the config-schema response. |
power_profile_reason |
string | null | null on success, otherwise no_profile_for_device / no_firmware_version / fw_out_of_range. |
estimators.battery |
object | null | The battery-life estimate when one can be produced. null when battery_reason or power_profile_reason is non-null (see “No-estimate cases” below). |
The estimators wrapper exists so that future non-battery estimates (storage budget, telemetry volume, …) can be added alongside without breaking clients.
estimators.battery fields
| Field | Type | Description |
|---|---|---|
total_avg_ma |
number | Sum of per-subsystem avg_ma, rounded to 3 decimals. Subsystems whose contribution cannot be characterised contribute 0. |
hours |
integer | null | Battery life at full charge, computed as battery.capacity_mah / total_avg_ma. null when the total is zero. Clients format to days / months / years as appropriate. |
confidence |
string | Share-weighted aggregate confidence: high, medium, low, or incomplete. |
variance_pct |
number | null | The aggregate variance, in percentage points, that produced the confidence bucket above. null when confidence is incomplete. |
subsystems |
array | Per-subsystem breakdown, sorted by descending avg_ma. See below. |
warnings |
array of string | Deduplicated, human-readable notes about the estimate — firmware reinterpretations of the proposed config, low-confidence subsystems, firmware-version mismatches, and uncharacterised subsystems. |
Per-subsystem entry (estimators.battery.subsystems[i])
| Field | Type | Description |
|---|---|---|
key |
string | Subsystem identifier (e.g. modem, microphone, base). The full set is per-product-type; consult the power profile for the product. |
category |
string | One of system, connectivity, measurement, position, power. Useful for grouping in a UI. |
avg_ma |
number | Average current contribution of this subsystem under the proposed config, rounded to 3 decimals. |
share_pct |
number | Share of total_avg_ma, rounded to 1 decimal. |
confidence |
string | Per-subsystem confidence: high, medium, low, or incomplete. |
warning |
string | null | An inline note when the subsystem itself carries one (e.g. “GPS power impact is not characterised”). The top-level warnings array is the authoritative list for the consumer; this field is for tooltips on the subsystem row. |
Example response (200) — happy path, L1 device with default config
{
"battery": {
"code": "03",
"description": "Li-Ion 2x4900 mAh",
"capacity_mah": 9800,
"nominal_voltage_v": 3.7
},
"battery_reason": null,
"power_profile": {
"min_gen": "03",
"min_fw": null,
"max_fw": null,
"measurement_basis": {
"source": "Spartalainen_virrankulutus.xlsx; modem re-measured 2026-05-18",
"measured_at": "2024-03-04",
"fw_version": "2.1.0"
}
},
"power_profile_reason": null,
"estimators": {
"battery": {
"total_avg_ma": 1.420,
"hours": 6901,
"confidence": "medium",
"variance_pct": 17.4,
"subsystems": [
{ "key": "microphone", "category": "measurement",
"avg_ma": 0.575, "share_pct": 40.5,
"confidence": "high", "warning": null },
{ "key": "particles", "category": "measurement",
"avg_ma": 0.367, "share_pct": 25.8,
"confidence": "high", "warning": null },
{ "key": "modem", "category": "connectivity",
"avg_ma": 0.211, "share_pct": 14.9,
"confidence": "medium", "warning": null },
{ "key": "base", "category": "system",
"avg_ma": 0.140, "share_pct": 9.9,
"confidence": "high", "warning": null },
{ "key": "gps", "category": "position",
"avg_ma": 0.000, "share_pct": 0.0,
"confidence": "incomplete",
"warning": "gps power impact is not characterised" }
],
"warnings": [
"estimate excludes: gps",
"modem contribution uses medium-confidence measurement"
]
}
}
}No-estimate cases. When the estimator cannot produce a battery-life number, estimators is null and the reason fields carry the explanation. Consumers can branch on estimators === null as the single check.
Mains-powered (battery_reason: "mains_powered"):
{
"battery": {
"code": "30",
"description": "No battery (external power)",
"capacity_mah": null,
"nominal_voltage_v": null,
"external_power": true
},
"mains_powered": true,
"battery_reason": "mains_powered",
"power_profile": { "...": "..." },
"power_profile_reason": null,
"estimators": null
}No power profile for this device generation (power_profile_reason: "no_profile_for_device" — typical for Gen-1 hardware):
{
"battery": { "...": "..." },
"battery_reason": null,
"power_profile": null,
"power_profile_reason": "no_profile_for_device",
"estimators": null
}Unknown battery code (battery_reason: "unknown_battery_code"):
{
"battery": null,
"battery_reason": "unknown_battery_code",
"unknown_battery_code": "99",
"power_profile": { "...": "..." },
"power_profile_reason": null,
"estimators": null
}Warnings vocabulary. The strings in warnings are not enumerated — they are human-readable messages aimed at the person configuring the device. Typical patterns:
<subsystem> <field> capped from <X> to <Y> by firmware (<gate> enabled)— aninput_capsrule clamped a config value. Example:microphone md capped from 80 to 50 by firmware (battery_powered true + power_save enabled).<subsystem> <field> set to <Y> by firmware (<gate>; was <X>)— aconditionsoverlay overrode a base subsystem parameter.<subsystem> contribution uses <medium|low>-confidence measurement— the subsystem’s measurement is less thanhighconfidence.estimate excludes: <subsystem>— a gated-on subsystem has no measurement at all; its contribution is treated as zero.device firmware <X> is older than profile fw <Y>— the device’s reported firmware predates the firmware version the profile was measured at.total current is zero; cannot compute hours— defensive guard; not expected in practice.
Clients should display these verbatim; do not parse them programmatically.
Error responses:
| Status | Body | Description |
|---|---|---|
| 400 | {"error": "...", "field": "<i\|si\|md\|e>"} |
Config validation failed (missing required field or value out of declared range). |
| 403 | Forbidden | User does not have access to this device. |
| 404 | {"error": "Device or product type not found"} |
The device or its product type is not on file. |
Product Catalog APIs DRAFT
Get battery codes DRAFT
| Method | Location |
|---|---|
| GET | product-catalog/battery-codes |
Get the runtime catalog of battery hardware codes referenced by product codes. Positions 8–9 of each 11-character product code encode a 2-character battery code; this endpoint resolves those codes to their human-readable description, capacity, and nominal voltage. The catalog is a small map (a few dozen entries at most) and is safe to fetch once and cache for the duration of a session.
Required role: any user
Example response (200)
{
"battery_codes": {
"02": {
"description": "Li-Ion 4900 mAh",
"capacity_mah": 4900,
"nominal_voltage_v": 3.7
},
"03": {
"description": "Li-Ion 2x4900 mAh",
"capacity_mah": 9800,
"nominal_voltage_v": 3.7
},
"30": {
"description": "No battery (external power)",
"capacity_mah": null,
"nominal_voltage_v": null,
"external_power": true
}
}
}| Field | Type | Description |
|---|---|---|
description |
string | null | Human-readable name of the battery. |
capacity_mah |
number | null | Nominal capacity in milliamp-hours. null for mains-powered entries. |
nominal_voltage_v |
number | null | Nominal voltage at full charge, in volts. null for mains-powered entries. |
external_power |
boolean | Optional. When true, the entry represents a mains-powered device with no internal battery; capacity_mah and nominal_voltage_v are null in this case. Absent for battery-backed entries. |
The same battery shape (plus a code field naming the catalog key) appears in the config-schema and usage-estimate responses for a single device.
Theme Configuration APIs DRAFT
Get default templates DRAFT
| Method | Location |
|---|---|
| GET | theme-config/default-templates |
Get default email and SMS templates from system configuration.
Required role: org-admin
Example response
{
"email": {
"alarm-notification": {
"en": {
"subject": "Alarm: {{location_name}}",
"body": "An alarm was triggered at {{location_name}}..."
},
"fi": {
"subject": "Hälytys: {{location_name}}",
"body": "Hälytys laukesi kohteessa {{location_name}}..."
}
}
},
"sms": {
"alarm-notification": {...}
}
}Preview template DRAFT
| Method | Location |
|---|---|
| POST | theme-config/preview |
| Body | Optional | Datatype |
|---|---|---|
| theme | no | String |
| template-type | no | String |
| language | no | String |
| theme-config | no | JSON object |
| example-variables | yes | JSON object |
Preview a rendered template with example variables.
Required role: org-admin
Example request
{
"theme": "loopshore",
"template-type": "alarm-notification",
"language": "en",
"theme-config": {
"email-templates": {
"alarm-notification": {
"en": {
"subject": "Alert: {{location_name}}",
"body": "Temperature exceeded at {{location_name}}"
}
}
}
},
"example-variables": {
"location_name": "Office Building A"
}
}Success response
{
"subject": "Alert: Office Building A",
"body": "Temperature exceeded at Office Building A"
}Send test message DRAFT
| Method | Location |
|---|---|
| POST | theme-config/send-test |
| Body | Optional | Datatype |
|---|---|---|
| theme | no | String |
| message-type | no | String |
| template-type | no | String |
| language | no | String |
| recipient | no | String |
| theme-config | no | JSON object |
| example-variables | yes | JSON object |
Send a test email or SMS message.
Required role: org-admin
Example request
{
"theme": "loopshore",
"message-type": "email",
"template-type": "alarm-notification",
"language": "en",
"recipient": "test@example.com",
"theme-config": {...}
}Success response
{
"success": true,
"message": "Test email sent to test@example.com"
}List themes DRAFT
| Method | Location |
|---|---|
| GET | theme-config/themes |
List available theme configurations. You see only your organization’s theme, and none when it is loopshore, which only Loopshore configures.
Required role: org-admin
Example response
[
{
"theme": "loopshore",
"enabled": true,
"smtp-password-is-set": true,
"email-from-addr": "Loopshore <noreply@loopshore.com>",
"smtp-host": "smtp.example.com",
"smtp-port": 587,
"service-domain": "app.loopshore.com",
"sms-sender-name": "Loopshore"
}
]Get theme DRAFT
| Method | Location |
|---|---|
| GET | theme-config/themes/{theme} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| theme | no | path | String (max 12 chars) |
Get configuration for a specific theme.
Required role: org-admin
Example response
{
"theme": "loopshore",
"enabled": true,
"smtp-password-is-set": true,
"email-from-addr": "Loopshore <noreply@loopshore.com>",
"smtp-host": "smtp.example.com",
"smtp-port": 587,
"smtp-user": "apikey",
"service-domain": "app.loopshore.com",
"sms-sender-name": "Loopshore",
"email-templates": {...},
"sms-templates": {...}
}Update theme DRAFT
| Method | Location |
|---|---|
| PUT | theme-config/themes/{theme} |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| theme | no | path | String |
| Body | Optional | Datatype |
|---|---|---|
| enabled | yes | Boolean |
| email-from-addr | yes | String |
| email-bcc-on-activate | yes | String |
| smtp-host | yes | String |
| smtp-port | yes | Integer |
| smtp-user | yes | String |
| smtp-password | yes | String |
| service-domain | yes | String |
| sms-sender-name | yes | String |
| email-templates | yes | JSON object |
| sms-templates | yes | JSON object |
Update theme configuration. Only include fields you want to change.
Required role: org-admin
Validation constraints:
smtp-port: Must be 1-65535sms-sender-name: Must be 1-11 alphanumeric charactersemail-from-addr: Must be valid email format (optionally with display name, e.g.,"Name <email@example.com>")
Example request
{
"smtp-host": "smtp.sendgrid.net",
"smtp-port": 587,
"smtp-user": "apikey",
"smtp-password": "SG.xxxxx"
}Device Problem Monitoring APIs DRAFT
Get active problems DRAFT
| Method | Location |
|---|---|
| GET | device-problems/active |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| device-id | yes | query | String |
| type | yes | query | String |
| severity | yes | query | String |
Get the active problems of the devices your organization holds now. Only confirmed problems with critical or error severity are listed.
Required role: org-admin
Example response
[
{
"id": 1,
"device-id": "device-001",
"type": "communication",
"severity": "critical",
"manifestation": "confirmed",
"status": "active",
"reported-at": "2024-06-01T10:30:00Z",
"code": "CONN_TIMEOUT",
"description": "Device not responding for 24 hours"
}
]Filter values:
| Parameter | Valid values |
|---|---|
| type | unknown, hw, sw, environmental, communication |
| severity | critical, error, warning, notice, info |
Get problem history DRAFT
| Method | Location |
|---|---|
| GET | device-problems/history |
| Parameter | Optional | Type | Datatype |
|---|---|---|---|
| start | no | query | String (RFC3339) |
| end | yes | query | String (RFC3339) |
| device-id | yes | query | String |
| type | yes | query | String |
| severity | yes | query | String |
Get historical device problems within a time range. Each problem is shown to the organization that held the device when the problem was reported, so problems from before your organization received a device are not included.
Required role: org-admin
Example response
[
{
"id": 1,
"device-id": "device-001",
"type": "communication",
"severity": "critical",
"manifestation": "confirmed",
"status": "resolved",
"reported-at": "2024-03-15T10:30:00Z",
"resolved-at": "2024-03-16T08:00:00Z",
"code": "CONN_TIMEOUT",
"description": "Device not responding"
}
]Data Types Reference
Timestamp Format
All timestamps use ISO 8601 format with timezone:
2024-06-01T10:30:00Z
User Roles
A user’s roles are their grants: each role is granted at a location, which also covers every location below it, or on the organization. Read and change them with Get user grants and Replace user grants.
Tiers, from the narrowest:
| Role | What it gives | Granted at |
|---|---|---|
guest-viewer |
Viewing a location in another organization. Set by Loopshore. | A location |
viewer |
Viewing and naming locations, notes, display settings, status alarms, personal API keys, webhooks and share links. | A location at building level or above |
operator |
Viewer, plus installing, removing and configuring devices. | A location at building level or above |
building-admin |
Operator, plus editing locations below, managing users, quantity alarms and alarm thresholds, and surveys. | A location at building level or above |
org-admin |
Everything in your organization. | The organization (group) |
Add-ons, which give nothing without a tier at or above the same location:
| Role | What it gives | Granted at |
|---|---|---|
alarm-subscriber |
Quantity alarm subscriptions. On a guest-viewer grant, quantity and device-status alarm subscriptions; set there by Loopshore. |
A location at building level or above |
threshold-editor |
Editing alarm thresholds. | A location at building level or above |
device-configurator |
Configuring devices. | A location at building level or above |
The role field on a user is user for everyone (Removed user fields). Use the grants, or widest-role in List users, for what a user may do.
Problem Types
| Type | Description |
|---|---|
unknown |
Unknown problem type |
hw |
Hardware issue |
sw |
Software issue |
environmental |
Environmental condition issue |
communication |
Communication/connectivity issue |
Problem Severity Levels
| Severity | Description |
|---|---|
critical |
Requires immediate attention |
error |
Error condition |
warning |
Warning condition |
notice |
Notable condition |
info |
Informational |
Problem Manifestation
| Manifestation | Description |
|---|---|
occasional |
Problem occurs intermittently |
confirmed |
Problem is confirmed/persistent |
Revision history
| Version | Date | Author | Description |
|---|---|---|---|
| 0.1 | 4.2.2021 | J. Ratilainen | Initial draft |
| 0.2 | 11.2.2021 | J. Ratilainen | Add api-keys |
| 0.2.1 | 20.4.2021 | J. Ratilainen | Clarify units |
| 0.2.2 | 18.5.2021 | J. Ratilainen | Add pressure difference |
| 0.3.0 | 8.9.2021 | J. Ratilainen | Add acceleration |
| 0.4.0 | 20.3.2023 | J. Ratilainen | Add GPS coordinates |
| 0.5.0 | 2.4.2023 | J. Ratilainen | Add shock |
| 0.6.0 | 19.2.2024 | J. Ratilainen | Add dewpoint, absolute_humidity and tvoc_density |
| 0.7.0 | 26.9.2024 | J. Ratilainen | Add support for extra quantities with _ |
| 0.8.0 | 3.3.2025 | J. Ratilainen | Add purpose & context to API keys |
| 0.9.0 | 24.4.2025 | J. Ratilainen | Add weight, pressure_diff_min and pressure_diff_max |
| 0.10.0 | 5.9.2025 | J. Ratilainen | Added CO. Added description to webhooks |
| 0.11.0 | 1.12.2025 | J. Ratilainen | Added radon, batt, rsrp, snr, uptime, power_control, full_tx_power. Converted document to md file |
| 0.12.0 | 23.1.2026 | J. Ratilainen | Added Admin APIs section with user, node, device, theme, and problem management endpoints |
| 0.13.0 | 13.3.2026 | J. Ratilainen | Add voc_index |
| 0.14.0 | 6.4.2026 | J. Ratilainen | Added User APIs [DRAFT] section: self-service, location tree, observation data, quantities, analysis, alarms, weather, location customization, surveys, i18n, short URLs. Added survey management and device PATCH to Admin APIs |
| 0.14.1 | 11.4.2026 | J. Ratilainen | Clarify alarms: device_problem not POST-creatable, add breach condition, event response fields, POST response example, upper-threshold alarm example |
| 0.14.2 | 12.4.2026 | J. Ratilainen | Rename privilege can-edit-alarms → alarm-editor, document available privileges and enforcement scope |
| 0.14.3 | 12.4.2026 | J. Ratilainen | Clarify battery alarms use location_threshold (not device_problem), add battery-low example, add caution about device-specific timing for batt and no-data alarms |
| 0.15.0 | 13.4.2026 | J. Ratilainen | Rewrite Device Installation Model: time-range records replace event model, add removal-time support, document update and device-specific list endpoints |
| 0.15.1 | 14.4.2026 | J. Ratilainen | Add missing weight-unit and pm-unit to user settings |
| 0.16.0 | 16.4.2026 | J. Ratilainen | Add location archive (soft-delete): archive/unarchive endpoints, archived_at field on node list, move validation for archive boundaries |
| 0.17.0 | 16.4.2026 | J. Ratilainen | Add device config-schema endpoint; document device configuration format, enable bit positions, and command strings |
| 0.18.0 | 18.4.2026 | J. Ratilainen | config-schema entries now include position, category, and behavior flags (inverted_logic, requires_reboot, requires_confirmation, globally_deprecated) |
| 0.19.0 | 20.4.2026 | J. Ratilainen | quantities-v2 accepts optional start/end time window; when both given, each location entry includes observed-quantities listing quantities from installations overlapping the window |
| 0.20.0 | 20.4.2026 | J. Ratilainen | last-quantities (when include-device-info=true) and device list now include product-name resolved server-side from the device-type prefix |
| 0.21.0 | 24.4.2026 | J. Ratilainen | Added POST password/send-reset-email (admin-triggered password reset email, 14-day token); clarified password/reset-request token TTL (1 hour) and per-user rate limiting; added active, password-reset-pending, activation-email, password-reset-email fields to user list response |
| 0.22.0 | 25.4.2026 | J. Ratilainen | User responses gain last-credential-login-at, last-refresh-at, and last-device-info (admin only). lastlogin reinterpreted as “latest activity” — the greater of credential login and mobile-session refresh. Applies to GET /user, GET /user/{id}, and GET /whoami; “Get user” response also documents active and password-reset-pending for parity with the list endpoint. |
| 0.23.0 | 27.4.2026 | J. Ratilainen | Install endpoint accepts optional end-current-installation? flag that atomically closes the device’s current open installation before creating the new one (move-device flow). |
| 0.23.1 | 27.4.2026 | J. Ratilainen | GET location/{id}/devices response now includes product-name (was missing from the example despite the 0.20.0 promise). |
| 0.24.0 | 29.4.2026 | J. Ratilainen | Add device default-config endpoint (GET device/{device-id}/default-config); admin/user clients no longer need to guess defaults when populating config forms. |
| 0.25.0 | 30.4.2026 | J. Ratilainen | Add device downsampled observations endpoint (observation/read-v2/device/{id}/downsampled); LTTB-downsampled time series for a single device. |
| 0.26.0 | 4.5.2026 | J. Ratilainen | Add Location Notes (CRUD) under Extended User APIs: time-pinned annotations per location with shared/private visibility, author-only edits/deletes for private notes, and author-only visibility flips into or out of private. |
| 0.27.0 | 18.5.2026 | J. Ratilainen | Add battery-life estimator: new POST device/{id}/usage-estimate, new GET product-catalog/battery-codes (Product Catalog APIs section), and four additive top-level fields on the device/{id}/config-schema response (power_profile, power_profile_reason, battery, battery_reason). |
| 0.28.0 | 2.6.2026 | J. Ratilainen | GET location/{id}/csv is now streamed (no size limit) and gains optional query params: quantities, compact, utc, local, tz, separator, decimal, bom, preview. Defaults unchanged. |
| 0.29.0 | 9.6.2026 | J. Ratilainen | last-quantities (with include-device-info=true) now returns a location-level device object sourced from the current installation, present even when the device has no in-scope measurements. Corrected the note that wrongly claimed an empty values map means no device is installed. |
| 0.30.0 | 16.6.2026 | J. Ratilainen | API keys can be created read-only: POST api_key accepts an optional read-only boolean, GET api_key returns read_only per key, and read-only keys are rejected (403) on any mutating HTTP method. |
| 0.31.0 | 2.7.2026 | J. Ratilainen | Added notable device settings: admin responses expose a notable-flags array ({key, tone, params}) highlighting attention-worthy config (capillary tubing, dense interval, power save off). Present on the device configuration read and the last-quantities device object; opt-in on the device list via include-notable-flags=true. |
| 0.32.0 | 10.8.2026 | J. Ratilainen | Added Device Live View: POST/GET lwm2m/device/endpoint/{endpoint}/live-view[/...] temporarily drops a device’s interval to its schema floor for near-real-time monitoring, auto-reverting after an idle timeout or hard deadline. PUT device configuration gains an optional expected-version optimistic-concurrency guard (409 on mismatch) and now validates config.i against the device’s config-schema interval range (400 if out of range; previously unvalidated). |
| 0.33.0 | 26.8.2026 | J. Ratilainen | Documented location/{id}/observations/downsampled (LTTB points plus window min/max/mean, no range limit) and added a Choosing an endpoint guide to Reading Observation Data. Clarified that observations/downsampled-v2 is a points-only variant; it now serves ranges of any length. Corrected the aggregates response: min/max are whole buckets selected by extreme value (not by average), avg is now weighted by each bucket’s observation count, the end bound is exclusive, and samples selects the bucket period as well as the LTTB target. The location downsampled endpoints now cap samples at 2000, matching the device one. All three downsampled endpoints may return truncated: true when a range is too large to read in full. |
| 0.34.0 | 4.9.2026 | J. Ratilainen | Added the locate sound command: c accepts "b" (schema key locate_sound), which makes the device play a chime for about a minute so it can be found. Documented that the device re-runs a stored c on every configuration version bump, so read-modify-write clients must clear it themselves. |
| 0.35.0 | 9.9.2026 | J. Ratilainen | Documented enable bit 21 (spl_pause_during_fan), previously listed as unused: the device pauses sound level measurement while the particle sensor fan runs, so readings no longer include the device’s own fan noise. Set by default. At intervals short enough to keep the fan running continuously the device reports no sound level at all. Updated the bitmask example to keep the bit set. |
| 0.35.1 | 18.9.2026 | J. Ratilainen | Documented the group-access field on List devices: the periods during which each device is assigned to the admin’s group. The field was already returned but not documented. |
| 0.36.0 | 30.9.2026 | J. Ratilainen | Added the group_suspended error: once an organization’s access to the service ends, login, token refresh and every authenticated request (including API keys) return 403 {"error": "group_suspended"}. Login returns it only after a correct password; refresh leaves the token usable for when access is restored. Added the group-access-ends-at field to Get current user. |
| 0.37.0 | 5.10.2026 | J. Ratilainen | List users and Get user gain last-api-key-used-at (share links included), last-webhook-delivered-at, last-alarm-sent-at, created-at and created-at-estimated; their lastlogin now counts all of these sources. API key and webhook times update at most once a day. List subscriptions gains last-delivered-at. Get current user is unchanged. Removed mentions of internal roles. |
| 0.38.0 | 6.10.2026 | J. Ratilainen | Added Suspend user and Unsuspend user. Login, Refresh token and every authenticated request can return 403 {"error": "user_suspended"}. List users and Get user gain suspended-at, suspended-by and suspended-by-name. |
| 0.39.0 | 6.10.2026 | J. Ratilainen | Added Get user impact: what suspending or deleting a user affects, with the last use of each item. |
| 0.40.0 | 7.10.2026 | J. Ratilainen | Missing, expired or invalid authentication now returns 401 on every endpoint that requires it. Most endpoints previously returned 403 in this case. A suspended user or organization now gets its user_suspended or group_suspended error on every endpoint, not a bare 403. Corrected Authorization: a resource you can’t access returns 403 or 404, not 401. Corrected the 401 example: the body is empty. |
| 0.41.0 | 9.10.2026 | J. Ratilainen | Get current user gains features and permissions. Added Users with access. Impersonation acts fully as the target and ends with the impersonator’s own access. Webhooks with scope “all” cover an admin’s whole organization and skip archived locations. Corrected the admins’ location scope. Announced upcoming changes: 404 for resources outside what you can see on DRAFT endpoints, and the alarm-editor check on Set alarm configuration. |
| 0.42.0 | 12.10.2026 | J. Ratilainen | Observation history and Latest observations describe which periods of a device’s data you receive. Latest observations return an empty list for a device currently installed at an archived location. |
| 0.43.0 | 12.10.2026 | J. Ratilainen | List devices no longer shows the location, installation time or notable settings of a device your organization doesn’t hold now. Get problem history shows each problem to the organization that held the device when it was reported. Described which devices and installations List devices, List installations and Get active problems return. |
| 0.44.0 | 13.10.2026 | J. Ratilainen | Get location tree contains every location you can view when none of your selected locations is available. Get analysis results, List survey-location assignments and Get survey results exclude archived locations. Get alarm events and Get alarm state return only alarms at locations you can view now. |
| 0.45.0 | 13.10.2026 | J. Ratilainen | The AI helper belongs to the organization. Added Set user AI helper setting: admins switch it off for a user, or back to following the organization. List users and Get user document feature-overrides. |
| 0.46.0 | 13.10.2026 | J. Ratilainen | A refused request on a DRAFT endpoint returns 404 for a location, group, device, installation or user outside what you can see, and 403 when you can see it but aren’t allowed the action. Previously some returned 403, and installing a device at a location outside your organization returned 400. Set alarm configuration requires the alarm-editor privilege for alarms on measured quantities. |
| 0.46.1 | 13.10.2026 | J. Ratilainen | Observation history applies the UTC offset of end; previously an offset other than Z was ignored and the window ended at that clock time in UTC. Described the order of the observations and what happens at the 5000-observation limit. |
| 0.46.2 | 13.10.2026 | J. Ratilainen | Observation history V2 (cursor-based): end is exclusive. |
| 0.47.0 | 15.10.2026 | J. Ratilainen | A share link that no longer works returns 403 {"error": "share_unavailable"} on every request, whatever the reason: deleted or archived location, location outside the owner’s access, or a suspended owner or organization. A suspended user’s share links previously returned user_suspended or group_suspended. An unknown API key returns 401 {"error": "invalid_api_key"} and is no longer passed over for a session cookie or Bearer token sent in the same request. |
| 0.47.1 | 16.10.2026 | J. Ratilainen | Set alarm configuration: an aqi-curve that doesn’t have two distinct points or doesn’t cross both score 2 and score 5 returns 400 and saves nothing; previously such a curve returned 500 or was saved. Leaving out aqi-curve is the same as null. Described that the custom curve belongs to the location, and how to build a curve from alarm and goal limits. |
| 0.48.0 | 16.10.2026 | J. Ratilainen | User nodes and privileges are read from the user’s access: deleted locations are no longer returned, a location in another organization is view-only and stays when an admin saves the user without it, and a user’s alarm-editor is returned only when they have locations in your organization. An admin’s nodes sets the locations shown first in the measurements tree and those their share links can show. |
| 0.49.0 | 19.10.2026 | J. Ratilainen | Observation history, the cursor-based histories, downsampled observations and Aggregated observations: only a few requests from one user account run at a time. A request over the limit returns 429 {"error": "too_many_requests"} with Retry-After, and a read that takes too long returns 504 {"error": "query_timeout"}. Added Concurrent history requests. |
| 0.50.0 | 19.10.2026 | J. Ratilainen | Update installation: a new installation-time before your organization had the device returns 403. |
| 0.51.0 | 19.10.2026 | J. Ratilainen | Added Get user grants and Replace user grants: roles, tiers and add-ons, who may add or remove which grant. |
| 0.52.0 | 20.10.2026 | J. Ratilainen | Users with access gains role, each user’s widest tier at the location. Create user takes grants, stored with the user. Creating a user or replacing grants returns 400 when you manage users in some locations only and the user would hold no role in them. |
| 0.53.0 | 20.10.2026 | J. Ratilainen | List devices gains location-name, the name of the device’s current location, and states who may list devices and which: Operators, Building admins and Device configurators get the devices installed at their locations. |
| 0.54.0 | 20.10.2026 | J. Ratilainen | List users gains manageable: false for a listed user you can read but not change. List users and List nodes state who may call them and what they return; Operators now get the locations they install devices at from List nodes. |
| 0.55.0 | 21.10.2026 | J. Ratilainen | Replace user grants and Create user give location grants at a building or above only: a new grant inside a building is 400. Earlier grants inside a building stay and can be removed. |
| 0.56.0 | 21.10.2026 | J. Ratilainen | Update node merges data into the stored data, and a key sent as null is removed; before, data replaced the stored data. data.address is a string of at most 500 characters. It states who may change what: the location a role is given at changes its own name and address. |
| 0.57.0 | 22.10.2026 | J. Ratilainen | notable-flags goes to everyone who sees the measurements in last-quantities, except a share link, and to everyone who may read a device’s configuration in the device configuration read. Before, only admins got it. |
| 0.58.0 | 22.10.2026 | J. Ratilainen | List users gains widest-role: the widest role the user holds. role stays for older clients. |
| 0.58.1 | 23.10.2026 | J. Ratilainen | Device and LWM2M endpoints state their required roles in the new roles; they had said admin or user. Configuring a device, its config schema, default config, battery-life estimate and Live View need org-admin, or building-admin, operator or device-configurator at the device’s location. Removing a device and changing or deleting an installation need org-admin, or building-admin or operator at the location. Installing a device needs org-admin. The configuration read, configuration history and LWM2M device list gain a required role; the history is org-admin only. Theme configuration and device problem endpoints gain a required role: org-admin. The Admin APIs introduction no longer says they need the admin role, and User Roles lists the tiers and add-ons instead of user and admin. Every Admin API, and every User API that changes a location’s settings, notes, images or alarms, states its required role. The descriptions of role, nodes and profile.privileges say how these legacy fields follow the grants and change them when written, and Update user says it doesn’t take grants. Access notes on observations, webhooks, alarms, archived locations, surveys, the LWM2M device list, themes, device problems and impersonation are written in terms of roles. The Theme rules no longer say you can assign only themes you have access to. Links to endpoint sections point to the right headings again. |
| 0.59.0 | 23.10.2026 | J. Ratilainen | Alarm thresholds are set separately from alarms. New Get location threshold, Set location threshold and Delete location threshold; setting and deleting need alarm threshold editing (org-admin, building-admin or threshold-editor). Set alarm configuration for a measured quantity needs only quantity alarm subscriptions (org-admin, building-admin or alarm-subscriber); aqi-curve null or left out keeps the location’s curve instead of removing it, and sending a different curve is deprecated. A curve change gives every alarm there a threshold event and returns it to idle. The docs no longer say a missing curve falls back to default thresholds for alarms: without a curve, threshold alarms don’t fire. |
| 0.60.0 | 23.10.2026 | J. Ratilainen | Share links: a write link writes notes and changes display settings only; it no longer sets alarms or changes derived quantities. A link works wherever its owner sees the shared location, an org-admin’s through their whole organization; the owner’s selected locations no longer limit it, and its tree is the shared location with the locations below it. Get current user permissions gains share with the shared location and the link’s mode. |
| 0.61.0 | 26.10.2026 | J. Ratilainen | Installing a device needs org-admin, or building-admin or operator at the location, and the device must be your organization’s at installation-time; it had needed org-admin. A move (end-current-installation?) also needs one of these roles where the device is installed now. 404 for a device or location outside what you can see, 403 otherwise. New List install candidates: your organization’s devices with no active installation. In User Roles, alarm-subscriber on a guest-viewer grant gives quantity and device-status alarm subscriptions there, set by Loopshore; Set alarm configuration accepts it for device-status alarms. |
| 0.62.0 | 26.10.2026 | J. Ratilainen | Replace user grants: an org-admin holds no viewer grant in their organization, nor the add-ons only such a grant carries, and a request that gives them stores the user without them; removing org-admin leaves the other grants in the set. Nodes: an org-admin’s locations in their organization are only the tree preference and give no grant. Get location tree: for a share link, the tree is the shared location alone; the locations below it, which a share link can’t read, are no longer listed. Get analysis results: a share link no longer gets the locations below the shared location. Theme: the theme belongs to the organization; a user in one has its theme, theme in Create user and Update user is deprecated and may only repeat it or be null, and an unknown theme answers 400. List themes: an org-admin sees their organization’s current theme. |
| 0.63.0 | 28.10.2026 | J. Ratilainen | New Deprecated user fields: nodes, profile.privileges and the role value admin are deprecated and will be removed; after that role is user for every user, and a user write sending the removed fields, or a different role, answers 400. Use permissions and the user grants instead. |
| 0.64.0 | 28.10.2026 | J. Ratilainen | Impersonate user: an org-admin may impersonate another org-admin of the organization; who may be impersonated follows the same rule as managing users. |
| 0.65.0 | 2.11.2026 | J. Ratilainen | Set alarm configuration: for a measured quantity, a request sending aqi-curve returns 400, also when it is the curve already set; set the curve with Set location threshold. null or no aqi-curve keeps the location’s curve, as before. batt, rsrp and snr are unchanged. |
| 0.66.0 | 2.11.2026 | J. Ratilainen | Removed user fields: nodes, profile.privileges and the role value admin are removed. role is user for every user, and Create user and Update user answer 400 to nodes, profile.privileges or a different role; repeating the current role changes nothing. Adding or removing org-admin in Replace user grants no longer changes role. New tree-locations in Update settings: the measurements tree preference, which was an org-admin’s nodes; see Profile. Update settings stores every setting sent, and Update user merges profile.settings key by key. |
| 0.67.0 | 9.11.2026 | J. Ratilainen | New known quantities pn0p5, pn1p0, pn2p5, pn4p0, pn0p3 (#/m3) and tps (um). New enable bit 22 particle_counts_off, inverted. Calibration data format: q is the measurement resource ID (the examples had wrong IDs), the calibratable quantities, at most 12 entries of 5 points. Write device configuration rejects a config longer than 990 bytes with 400. |
LOOPSHORE | Hämeenkatu 13 A 5, 33100 Tampere | info@loopshore.com | loopshore.com