Loopshore API
API Reference
Version 0.35.1
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 is only allowed to access the resources that are configured to the user by system admins. If you are trying to access a resource that you have not access to, 401 error code will be returned. Contact 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.
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 |
| 404 | Not found - resource doesn’t exist or isn’t accessible |
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 or expired:
{"error": "Unauthorized", "message": "Authentication required"}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"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 and retry once
- 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 |
| 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.
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...
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 |
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.
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.
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”.
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
}]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. 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",
"nodes": [5, 12, 47],
"profile": {
"privileges": ["alarm-editor"]
},
"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"
}Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | User ID |
name |
string | Username |
role |
string | User role (user or admin) |
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 |
nodes |
int[] | Location IDs user can access |
profile |
object | User preferences and privileges |
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. |
Admins and superadmins additionally receive last-device-info (the device_info string from the most recent mobile session). It is stripped from this endpoint for regular users; see the admin user endpoints for the full shape.
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) |
Update display unit preferences. All fields are optional — only include fields you want to change. Settings are synced across web and mobile.
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 admin-initiated reset emails 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.
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.
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.
Both users and admins receive data only for devices and time windows they have access to. 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 only foradmin/superadmincallers, 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. - 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).
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: Custom threshold curve for the quantity. If
null, uses system default thresholds. 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).
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).
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 |
Alarm threshold was changed |
threshold_deleted |
Custom threshold removed (reverted to defaults) |
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 all alarms configured by the current user.
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 | Custom threshold curve, or null for system defaults |
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 | Array (threshold curve, null to use defaults) |
| 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".
Example request
{
"delay": 30,
"hysteresis": 15,
"medium": [1, 2],
"config-type": "location_threshold",
"quantity": "temperature",
"aqi-curve": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]],
"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 | Custom threshold curve, or null if defaults are in use. 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.
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- Setting
aqi-curveto null removes the custom curve and reverts to system defaults
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.
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.
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).
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.
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.
| 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.
| 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.
| 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) |
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) |
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.
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 require admin role and are used for managing users, devices, and locations. Admin users can only manage resources within their assigned scope.
Scope and Permissions:
Admin users are restricted to their assigned group. This means:
- Users: Can only see and manage users in the same group
- Locations: Can only access locations assigned to their user account
- Devices: Can only manage devices at accessible locations
- Theme: Can only use themes assigned to their group
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
Admin 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
Nodes (Location Access)
The nodes field is an array of location IDs that determine which parts of the location tree a user can access. Locations are organized hierarchically (building → floor → room), and each location has a numeric ID.
"nodes": [5, 12, 47]How it works:
- A user sees only locations whose IDs are in their
nodesarray (and their descendants) - An empty array
[]means no location access nullmeans the user has not been assigned specific locations yet
Use GET /api/node to list available locations and their IDs.
Note: Access should be granted to at least building level. Granting access to only a single room may cause UI issues — the user cannot navigate the location hierarchy and the frontpage will be empty.
Theme
The theme field links the user to a 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"Rules:
- Must reference an existing theme in the system (validated on save)
- If not set or
null, defaults to"loopshore" - Admin users can only assign themes they have access to
Use GET /api/theme-config/themes to see available themes.
Profile
The profile field stores user preferences as a JSON object:
"profile": {
"privileges": ["alarm-editor"]
}| Field | Type | Description |
|---|---|---|
privileges |
string[] | Feature flags/permissions for the user |
Available privileges:
| Privilege | Description |
|---|---|
alarm-editor |
Allows the user to configure alarm settings in the UI |
Note: Privileges are currently used as hints to the user interface (e.g. hiding or showing alarm configuration controls). They are not enforced by the backend API — a future version may add server-side enforcement.
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.
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) |
Admin-triggered: email a password reset link to an existing user. 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 the admin can safely resend. Not rate-limited. Returns 404 for unknown users — enumeration defenses are unnecessary behind admin auth.
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— admin onboarding: sends an activation email to a newly-created user who has not set a password yet.POST password/send-reset-email— admin recovery: emails a 14-day reset link to an existing user.GET password/reset— admin 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 the admin needs to deliver the link out-of-band (SMS, chat) rather than via email.
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.
Example response
[
{
"id": 1,
"name": "admin@example.com",
"role": "admin",
"email": "admin@example.com",
"language": "en",
"theme": "loopshore",
"nodes": [1, 2, 3],
"profile": {"privileges": ["alarm-editor"]},
"emailConfirmed": true,
"lastlogin": "2024-06-01T10:30:00Z",
"last-credential-login-at": "2024-05-28T08:00:00Z",
"last-refresh-at": "2024-06-01T10:30:00Z",
"last-device-info": "Pixel 9 Pro",
"active": true,
"password-reset-pending": false,
"activation-email": {
"sent-at": "2024-05-28T09:15:00Z",
"sent-by-name": "superadmin",
"status": "ok",
"error-message": null
},
"password-reset-email": null
}
]Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | User ID |
name |
string | Username |
role |
string | User role (user or admin) |
email |
string | Email address |
language |
string | UI language code |
theme |
string | Theme identifier |
nodes |
int[] | Location IDs user can access |
profile |
object | User preferences |
emailConfirmed |
boolean | Whether email has been verified |
phonenum |
string | Phone number |
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. |
last-device-info |
string | null | device_info string from the most recent mobile session. null if no mobile session has ever been started. Returned to admin/superadmin viewers only. |
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 |
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 admin 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 |
| nodes | yes | Integer[] |
| profile | yes | JSON object |
Create a new user. If password is omitted, user must reset via activation email.
Example request
{
"name": "maintenance@example.com",
"email": "maintenance@example.com",
"role": "user",
"language": "fi",
"nodes": [25, 26, 27]
}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.
Example response
{
"id": 15,
"name": "user@example.com",
"email": "user@example.com",
"role": "user",
"language": "en",
"theme": "loopshore",
"nodes": [5, 6],
"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",
"active": true,
"password-reset-pending": false
}Response fields:
| Field | Type | Description |
|---|---|---|
id |
integer | User ID |
name |
string | Username |
role |
string | User role (user or admin) |
email |
string | Email address |
language |
string | UI language code |
theme |
string | Theme identifier |
nodes |
int[] | Location IDs user can access |
profile |
object | User preferences |
emailConfirmed |
boolean | Whether email has been verified |
phonenum |
string | Phone number |
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. |
last-device-info |
string | null | device_info string from the most recent mobile session. null if no mobile session has ever been started. Returned to admin/superadmin viewers only. |
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 |
| nodes | yes | Integer[] |
| profile | yes | JSON object |
| phonenum | yes | String |
Update user details. Only include fields you want to change.
Example request - Promote user to admin:
{
"role": "admin",
"nodes": [1, 2, 3, 4, 5]
}Note: When updating nodes, the entire list is replaced.
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.
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.).
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.
Example response
{
"available": true
}Impersonate user DRAFT
| Method | Location |
|---|---|
| POST | imposter |
| Body | Optional | Datatype |
|---|---|---|
| name | no | String |
Login as another user for support purposes.
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"
}Node/Location Management APIs DRAFT
List nodes DRAFT
| Method | Location |
|---|---|
| GET | node |
List all nodes visible to the current admin. Returns a flat list.
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.
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.
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.
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": {"area_sqm": 550, "renovated": true}
}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).
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 regular users, stop generating analyses and alarms, and do not trigger webhooks or public surveys. All data is preserved and can be restored by unarchiving.
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).
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).
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.
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.
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 all devices visible to the current admin.
Required role: superadmin, admin
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,
"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,
"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). A device with a future removal-time is still considered installed. |
installation-time |
datetime | When the current installation started (null if not installed) |
path |
string | Location path for current installation (null if not installed) |
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 is computed only for admin and superadmin callers and appears in three places: the device list above (with include-notable-flags=true), the location-level device object of last-quantities, and the device configuration read.
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).
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: superadmin, admin
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.
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 400 if the device is not currently installed anywhere.
Required role: superadmin, admin
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 all installation records across all devices, ordered by installation time (newest first).
Required role: superadmin, admin
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: superadmin, admin
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).
Required role: superadmin, admin
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: superadmin, admin
Returns 200 with the deleted record on success.
Survey Management APIs DRAFT
These APIs allow admins to 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.
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 which locations.
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.
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.
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 the admin has access to. Results include both choice and free-text responses.
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": 32, "p": [[0.0, 0.0], [100.0, 99.5]]},
{"q": 5, "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 ID being calibrated (e.g. 5 = CO2, 32 = differential pressure). |
p |
array | Calibration points, each a [raw, calibrated] pair sorted by raw ascending. |
Example cal value:
[
{"q": 32, "p": [[0.0, 0.0], [100.0, 99.5]]},
{"q": 5, "p": [[400.0, 410.0], [1000.0, 998.0]]}
]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 400 ppm) | |
| 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–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 on, TVOC index on, 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 27 = 1 + 8 + 16 + 4 + 1048576 + 2097152 + 134217728 = 137363485
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.
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. For admins without current access to a device, the
config-*,config,comment,target-hw, and allfirmware-*fields are omitted from that device’s row entirely (not set to null). Superadmins always see every field. 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.
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 only for admin/superadmin callers, 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.
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: superadmin, admin, factory
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).
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 |
| 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: superadmin, admin, factory
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: superadmin, admin, user
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: superadmin, admin, user
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: superadmin, admin, user
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 config-UI admins. 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: superadmin, factory, admin, 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.
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.
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.
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. Admin users see only their own theme.
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.
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.
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 all active device problems. Admin users see only confirmed problems with critical/error severity.
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.
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
| Role | Description |
|---|---|
user |
Regular user access to assigned locations |
admin |
Administrative access within assigned scope |
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/superadmin 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/superadmin 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. |
LOOPSHORE | Hämeenkatu 13 A 5, 33100 Tampere | info@loopshore.com | loopshore.com