Loopshore API

API Reference

Version 0.67.0

Introduction

Loopshore API contains interfaces for reading and writing environmental sensor data from and to Loopshore cloud service.

APIs are HTTP based REST APIs. Only HTTPS is supported.

Terminology

Observation – a Physical or logical set of value, quantity and timestamp. Usually a measurement result.

Webhook – a way to deliver device observations to 3rd party servers.

API location

Property Value
Base URL https://service.loopshore.com/api
Port 443

All endpoint paths in this document are relative to the base URL. For example, user/{user-id} means the full path is https://service.loopshore.com/api/user/{user-id}.

Authentication

There are four ways to authenticate to the API.

You can authenticate to the API using username and password. Obtaining these credentials is out of scope of this document. Workflow is as follows:

Bearer token (mobile)

Mobile and programmatic clients use short-lived access tokens with long-lived refresh tokens. Workflow is as follows:

Custom ingestion URL

Ask URL from Loopshore.

API keys

Users can use their credentials to acquire an API key that has the same privileges as the username and password. Using an API key might be easier, faster and less resource consuming than acquiring the session key for each request. Note that you have to handle the API keys with the same precautions as you handle the password.

A key can optionally be created as read-only. A read-only key keeps the same read access as its owner but cannot modify anything: every mutating request (any HTTP method other than GET/HEAD/OPTIONS) is rejected with 403 Forbidden. Read-only keys are recommended for integrations that only need to read data, so a leaked key cannot be used to change anything.

Authorization

Each user may access only what their roles give them, at the locations where the roles are granted; see User Roles. If you try to access a resource you don’t have access to, the API returns 403 Forbidden (or 404 Not Found where it does not reveal whether the resource exists). 401 Unauthorized means only that the request has no valid authentication; see Error handling. Ask the person who manages your user account, or Loopshore, if you think that you should have access to a resource.

Restrictions

A reasonable usage of APIs is expected from API users. Currently reasonable usage limit is once/minute/device/day, which should cover all the normal use cases. API access may be temporarily blocked if the user exceeds this amount.

If you want to exceed this restriction, contact Loopshore.

Concurrent history requests

Reading observation history is the heaviest work the API does, so the service accepts only a few history requests from one user account at a time, counted over all its API keys and sessions. This applies to Observation history, both cursor-based histories, every downsampled observations endpoint and Aggregated observations. A request over the limit waits a while for its turn before it is answered, so a client that sends its history requests one or two at a time never notices the limit.

When the service cannot take a request, it answers 429 Too Many Requests with {"error": "too_many_requests"} and a Retry-After header giving the seconds to wait. Wait at least that long and send the request again; do not retry in a tight loop.

A history read that takes too long is stopped and answered 504 Gateway Timeout with {"error": "query_timeout"}. Narrow the request, a shorter time window or a single quantity, and send it again.

Example 1

User has access to two devices, that each report new measurements every 10 minutes. User polls the latest measurements for each device every 5 minutes.

API Usage

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

Requests (446) is below the limit (2880) so API usage is ok.

API descriptions

Content types

APIs are HTTP REST APIs. APIs accept two types of content: JSON and transit+JSON. If user does not give Content-Type (in POST) or Accept header (in GET), service assumes JSON.

Error handling

The API uses standard HTTP status codes to indicate success or failure:

Status Code Meaning
200 Success with response body
204 Success with no content
400 Bad request - invalid parameters or validation failure
401 Unauthorized - missing or invalid authentication
403 Forbidden - insufficient permissions, a read-only API key used for a change, or a suspended user or organization
404 Not found - resource doesn’t exist or isn’t accessible
429 Too many requests - wait for the seconds in Retry-After, then send the request again; see Restrictions
504 Gateway timeout - a history read took too long; narrow it and send it again; see Restrictions

A refused request answers 404 when the resource doesn’t exist or is outside what you can see, and 403 when you can see it but aren’t allowed the action. This applies to locations, groups, devices, installations and users.

Error responses are returned as JSON. The format varies but typically includes an error or message field:

{
  "error": "Error type",
  "message": "Detailed description"
}

Common error examples:

Authentication missing, expired or invalid (no session, an expired or invalid token, or an unknown API key) returns 401 on every endpoint that requires authentication, with a WWW-Authenticate: Bearer header. The body is empty, except for an unknown API key, which returns {"error": "invalid_api_key"}. A request with an x-api-key header is authenticated by that key only: if the key is unknown, a session cookie or Bearer token in the same request is not used. A 401 never means that you lack permission for the resource; that is a 403.

Validation failure (e.g., creating user with invalid data):

{"error": "Bad Request", "message": "Unsupported language"}

Resource not found or outside your scope:

"No such user"

Access to the service has ended for the user’s organization (all authenticated requests, login and token refresh):

{"error": "group_suspended"}

The user has been suspended (see Suspend user) (all authenticated requests, login and token refresh):

{"error": "user_suspended"}

A share link no longer works (all requests made with it). This is the only error a share link gives for its location being deleted or archived, the location being outside what the link’s owner can see, or the owner or their organization being suspended:

{"error": "share_unavailable"}

Duplicate username:

{"error": "Conflict", "message": "Username already exists"}

Invalid path format when creating location:

{"error": "Bad Request", "message": "Path must match pattern: label.label.label"}

Error handling guidance:

Known quantities

APIs are designed so that you can practically send any kind of information/observations to the system and fetch those via APIs. There is however, some predefined and known data types that have special meaning. These should be used when possible, because then for example the user interface recognizes the types.

Quantities and units are case sensitive.

Some quantities are calculated by system if not sent by device and source data is available. These are marked with asterisk (*) in the list below.

Quantity Unit Datatype Description
temperature C Number Temperature
humidity % Number Relative humidity
tvoc ppb Number Total Volatile Organic Compounds
voc_index index Number VOC Index (relative air quality indicator, 1-500)
co2 ppm Number Carbon Dioxide
pm1p0 ug/m3 Number Particles < 1.0um
pm2p5 ug/m3 Number Particles < 2.5um
pm4p0 ug/m3 Number Particles < 4.0um
pm10p0 ug/m3 Number Particles < 10um
pressure hPa Number Atmospheric pressure
light lx Number Ambient light level
laeqx dB Number A-weighted equivalent ambient sound pressure level since last measurement of the same type
lafmin dB Number A-weighted minimum sound pressure level since last measurement of the same type
lafmax dB Number A-weighted maximum sound pressure level since last measurement of the same type
lzeqx dB Number Z-weighted equivalent ambient sound pressure level since last measurement of the same type
lzfmin dB Number Z-weighted minimum sound pressure level since last measurement of the same type
lzfmax dB Number Z-weighted maximum sound pressure level since last measurement of same type
pressure_diff Pa Number Pressure difference
pressure_diff_min Pa Number Minimum pressure difference since last measurement of the same type
pressure_diff_max Pa Number Maximum pressure difference since last measurement of the same type
accxposmax m/s2 Number Maximum acceleration along X-Axis in positive direction since last measurement of the same type
accxnegmax m/s2 Number Maximum acceleration along X-Axis in negative direction since last measurement of same type
accyposmax m/s2 Number Maximum acceleration along Y-Axis in positive direction since last measurement of same type
accynegmax m/s2 Number Maximum acceleration along Y-Axis in negative direction since last measurement of same type
acczposmax m/s2 Number Maximum acceleration along Z-Axis in positive direction since last measurement of same type
accznegmax m/s2 Number Maximum acceleration along Z-Axis in negative direction since last measurement of same type
latitude d Number GPS coordinate in degree/angle
longitude d Number GPS coordinate in degree/angle
altitude m Number Altitude from sea level in meters
shock m/s2 Number Acceleration shock
tvoc_density ug/m3 Number Total Volatile Organic Compounds
absolute_humidity* g/m3 Number Absolute humidity
dewpoint* C Number Dewpoint
weight kg Number Weight of something
co ppm Number Carbon monoxide
radon Bq/m3 Number Radon level
pn0p5 #/m3 Number Number of particles 0.5–10 µm
pn1p0 #/m3 Number Number of particles 1.0–10 µm
pn2p5 #/m3 Number Number of particles 2.5–10 µm
pn4p0 #/m3 Number Number of particles 4.0–10 µm
pn0p3 #/m3 Number Number of particles 0.3–10 µm
tps um Number Typical particle size
batt % Number Battery level
rsrp dBm Number Reference Signal Received Power (cellular signal strength)
snr dB Number Signal to Noise Ratio
uptime s Number Device uptime
power_control Number Power control state
full_tx_power s Number Full transmit power duration

Note! System also recognizes quantities that are post fixed with _<number>. So you can for example send three temperatures from the same device by naming them temperature, temperature_1 and temperature_1234.

User APIs STABLE

These APIs are available to all authenticated users for reading and writing observation data. Each returns only what your roles give you (see User Roles).

Login STABLE

Method Location
POST token
Body Optional Datatype Description
name no String Username
password no String Password
grant_type yes String "cookie" (default) or "mobile"
device_info yes String Device description (e.g. "iPhone 15") — only used with grant_type: "mobile"

The login endpoint supports two modes depending on the grant_type parameter.

When grant_type is omitted or "cookie", the response sets a session cookie. This is the mode used by web applications.

Example request

{
  "name": "user@example.com",
  "password": "secret"
}

Example response header

Set-Cookie: jabster_token=<token>;Max-Age=43200

Example use of cookie in a request

Cookie: jabster_token=<token>

Mobile mode

When grant_type is "mobile", the response returns access and refresh tokens in the body. No cookie is set.

Example request

{
  "name": "user@example.com",
  "password": "secret",
  "grant_type": "mobile",
  "device_info": "iPhone 15 Pro"
}

Example response

{
  "user": "user@example.com",
  "ts": "2025-01-15T10:30:00Z",
  "access_token": "eyJhbGciOiJIUzUxMiJ9...",
  "refresh_token": "a1b2c3d4e5f6...",
  "token_type": "Bearer",
  "expires_in": 900
}

Response fields (mobile mode):

Field Type Description
user string Username
ts string Login timestamp (ISO 8601)
access_token string Short-lived JWT access token (15 minutes)
refresh_token string Long-lived opaque refresh token (30 days)
token_type string Always "Bearer"
expires_in integer Access token lifetime in seconds (900)

Example use of access token in a request

Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...

Error responses:

Status Body Description
403 "Invalid credentials." Wrong username or password
403 {"error": "group_suspended"} Correct credentials, but the organization’s access to the service has ended. Returned only after a correct password.
403 {"error": "user_suspended"} Correct credentials, but the user has been suspended. Returned only after a correct password.

Refresh token STABLE

Method Location
POST token/refresh
Body Optional Datatype Description
refresh_token no String The refresh token received from login or a previous refresh

Exchange a valid refresh token for a new access token and refresh token pair. This endpoint does not require authentication — the refresh token itself serves as the credential.

Token rotation: Each refresh request invalidates the old refresh token and issues a new one. Always store and use the latest refresh token.

Reuse detection: If a previously rotated (invalidated) refresh token is used, all tokens in the session are revoked for security. The user must log in again.

Example request

{
  "refresh_token": "a1b2c3d4e5f6..."
}

Example response

{
  "access_token": "eyJhbGciOiJIUzUxMiJ9...",
  "refresh_token": "f6e5d4c3b2a1...",
  "token_type": "Bearer",
  "expires_in": 900
}

Error responses:

Status Body Description
401 {"error": "invalid_refresh_token"} Token not found, expired, or already rotated
401 {"error": "token_reuse_detected"} Reuse of a rotated token — all session tokens revoked
403 {"error": "group_suspended"} The organization’s access to the service has ended. The refresh token is not consumed, so it works again if access is restored.
403 {"error": "user_suspended"} The user has been suspended. The refresh token is not consumed, so it works again once the user is unsuspended.

Acquire apikey STABLE

Method Location
POST api_key
Body Optional Datatype
key-name no String
read-only yes Boolean
purpose yes String
context yes JSON

Acquire an apikey (random hexstring) that can be used instead of session style login. key-name is a documentary string that can be used to identify different keys. Purpose and context should be omitted.

Set read-only to true to create a key that can read data but cannot perform any modifications (see API keys). It defaults to false. A read-only key still has the same read access as its owner.

Response contains a secret-key that can be used in HTTP header to authenticate a user. Treat the secret-key with the same precautions as a password.

There is no way to recover a lost secret-key. In case a secret-key is lost, make a new one and preferably delete the lost key from the service.

id is used to identify a key in other APIs.

Example request

{
  "key-name": "my-read-only-key",
  "read-only": true
}

Example response

{
  "id": 123,
  "secret-key": "ccb22ee3fe90ce30d20654ee214307755806f53dbb84",
  "read-only": true
}

Notes:

  1. The purpose and context request fields should be omitted. They refer to restricted access modes that are not relevant for the APIs documented here.

apikey usage

Apikey is used by putting the key to HTTP header with name x-api-key.

Example header

x-api-key:ccb22ee3fe90ce30d20654ee214307755806f53dbb84

List apikeys STABLE

Method Location
GET api_key

List all the user’s api-keys with key-name and id. Each entry also includes read_only, indicating whether the key is restricted to read-only access.

Example response

[{
  "id": 123,
  "key-name": "my-api-key",
  "purpose": "all",
  "read_only": false
},
{
  "id": 567,
  "key-name": "my-iframe-view-key",
  "purpose": "sharer",
  "context": {"any": "thing"},
  "read_only": false
},
{
  "id": 891,
  "key-name": "my-read-only-key",
  "purpose": "all",
  "read_only": true
}]

Remove apikey STABLE

Method Location
DELETE api_key
Body Optional Datatype
id no Number

Removes an api-key from the service.

Example request

{
  "id": 123
}

Observation history STABLE

Method Location
GET observation/read/device/{device-id}
Parameter Optional Type Datatype
device-id no path String
start no query String (RFC3339)
end yes query String (RFC3339)
quantity yes query String

Returns all observations for given device-id from the start timestamp to current time.

Result is limited to 5000 observations.

Optionally, end timestamp and quantity filter can be given.

start time is inclusive and end time is exclusive. Both are points in time, so a UTC offset is applied: end=2021-03-01T00:00:00+02:00 ends the window at 22:00 UTC on 28 February. Encode the + as %2B in the URL.

Observations are ordered by timestamp, and observations with the same timestamp by quantity. When the result reaches the 5000-observation limit, the observations at its last timestamp may be cut short. Page by fixed time windows that stay under the limit, as in the usage example below. Starting the next request from the last returned timestamp can repeat or miss observations.

Access. You receive observations from the periods the device was installed at a location you have access to. An org-admin instead receives observations from the periods the device belonged to their organization, wherever it was installed. Outside these periods the result is an empty list.

Result is a JSON list of objects where each object presents a single quantity-value-time observation. Timestamp, value and quantity are always present. Unit is optional.

Response example

[
  {
    "timestamp": "2021-02-03T11:29:31.229Z",
    "value": 21.4,
    "quantity": "temperature",
    "unit": "C"
  },
  {
    "timestamp": "2021-02-03T11:23:12Z",
    "value": 442,
    "quantity": "co2",
    "unit": "PPM"
  }
]

Usage example

You want to get one year history of temperature from a device. You know that a device sends data mostly with once / 10 minute intervals. This means that you get 6×24=144 datapoints in a day and 144×30=4320 datapoints in a month. This falls within API 5000 result rows limit.

Because start time is inclusive and end time is exclusive, you can now call the API like this:

/read/device/my-device-id?start=2020-01-01T00%3A00%3A00Z&quantity=temperature&end=2020-02-01T00%3A00%3A00Z

/read/device/my-device-id?start=2020-02-01T00%3A00%3A00Z&quantity=temperature&end=2020-03-01T00%3A00%3A00Z

Latest observations STABLE

Method Location
GET observation/read/device/{device-id}/last-values
Parameter Optional Type Datatype
device-id no path String

Returns the latest observations of each quantity for given device-id.

This API is optimized for “poll latest observations” use case.

The same access rule as in Observation history applies. A device currently installed at an archived location returns an empty list.

Response example

[
  {
    "timestamp": "2021-02-03T11:29:31.229Z",
    "value": 21.4,
    "quantity": "temperature",
    "unit": "C"
  }
]

Subscribe observations STABLE

Method Location
POST webhook
Body Optional Datatype
url no String (valid URL, HTTP or HTTPS)
device-id yes String (either device-id or scope required)
scope yes String (either device-id or scope required)
x-headers yes JSON object
description yes String

Posts a new webhook either given device-id or scope.

Webhooks or subscriptions are used to subscribe data to 3rd party server in real time. When observations from device arrive to Loopshore service, it immediately forwards them to all interested stakeholders.

Webhook must contain a valid url where the data is to be sent.

Webhooks can be targeted to single device-ids or all the devices that user has access to (scope). Only valid value for scope is “all”. “all” covers every device installed at a location where you hold a tier from viewer up; for an org-admin, every location of their organization. Observations from devices at archived locations are not sent.

Optionally HTTP message sent from Loopshore can contain custom headers (x-headers).

Simple example for single device

{
  "url": "http://my-insecure-server.io/my-api",
  "device-id": "my-device-id"
}

More complex example for all devices with custom headers

{
  "url": "https://my-secure-server.io/my-api",
  "scope": "all",
  "description": "Just for testing, to be deleted",
  "x-headers": {
    "apikey": "secret",
    "target": "all-loopshore-devices"
  }
}

Response

Response contains all the data that was posted to API plus id and nonce fields.

Body Optional Datatype
id no String (UUID)
nonce no String

id - can be used to identify this particular webhook in other APIs.

nonce - is a random string that is sent along with forwarded observations and it can be used to identify what hook generated the POST.

Example response

{
  "id": "a0c03d18-378e-4326-b1d5-b47d9248be1e",
  "url": "https://my-secure-server.io/my-api",
  "scope": "all",
  "description": "Just for testing, to be deleted",
  "nonce": "_izF930v",
  "x-headers": {
    "apikey": "secret",
    "target": "all-loopshore-devices"
  },
  "device-id": null
}

List subscriptions STABLE

Method Location
GET webhook

This API can be used to list all the subscriptions/webhooks.

Example response

[{
  "id": "a0c03d18-378e-4326-b1d5-b47d9248be1e",
  "url": "https://my-secure-server.io/my-api",
  "scope": "all",
  "description": "Just for testing, to be deleted",
  "nonce": "_izF930v",
  "x-headers": {
    "apikey": "secret",
    "target": "all-loopshore-devices"
  },
  "device-id": null,
  "last-delivered-at": "2024-06-01T00:00:00Z"
}]

last-delivered-at – last time an observation was delivered to the url successfully, or null if never recorded. Updated at most once a day, so it can lag by up to a day.

Cancel observation subscription STABLE

Method Location
DELETE webhook/{id}
Parameter Optional Type Datatype
id no path String (UUID)

This API can be used to remove a subscription.

Test subscriptions STABLE

Method Location
POST webhook/{id}/test
Parameter Optional Type Datatype
id no path String (UUID)
Body Optional Datatype
nonce no String
data no JSON object

Sends a test message to webhook url.

You can test your webhook counterpart by posting a custom message to this API. Service then forwards the message to your server.

nonce – is the nonce for the webhook id.

data – any valid JSON. When real message arrives, data contains the same data that came in to the system via Send observations API.

Example message sent to url server

{
  "nonce": "_izF930v",
  "data": {"my": "test data"}
}

Example of message identical to real data

{
  "nonce": "_izF930v",
  "data": {
    "device-id": "123456789",
    "observations": [
      {
        "unit": "C",
        "value": 19.24,
        "quantity": "temperature",
        "timestamp": "2021-02-03T11:23:12Z"
      }
    ]
  }
}

Send observations STABLE

Method Location
POST observation/{token}/v2
Body Optional Datatype
device-id no String
observations no List (observation Object)
observation.timestamp no String (RFC3339)
observation.quantity no String
observation.value no Number/String
observation.unit no String

Posts new observations for given device-id. For getting your own token, contact Loopshore.

Body example

{
  "device-id": "my-device-id",
  "observations": [
    {
      "timestamp": "2021-02-03T11:29:31.229Z",
      "value": 21.4,
      "quantity": "temperature",
      "unit": "C"
    }
  ]
}

Extended User APIs DRAFT

These APIs are available to all authenticated users, and each returns only what your roles give you (see User Roles). Reading needs only that you see the location; an endpoint that needs more says so on its Required role line. They extend the stable APIs above with additional features for managing user preferences, browsing locations, reading measurement data, configuring alarms, and more.

Self-Service DRAFT

Get current user DRAFT

Method Location
GET whoami

Returns the profile data of the currently authenticated user.

Example response

{
  "id": 1,
  "name": "user@example.com",
  "role": "user",
  "email": "user@example.com",
  "emailConfirmed": true,
  "language": "fi",
  "theme": "loopshore",
  "profile": {},
  "phonenum": "+358501234567",
  "lastlogin": "2024-06-01T10:30:00Z",
  "last-credential-login-at": "2024-05-28T08:00:00Z",
  "last-refresh-at": "2024-06-01T10:30:00Z",
  "features": {"ai-helper": false},
  "permissions": {
    "locations": [
      {
        "location-id": 5,
        "path": "root.customer.hq",
        "permissions": ["alarm/subscribe-status", "derived-quantity/manage", "location/display-settings",
                        "location/layout", "location/rename", "location/view", "note/write",
                        "personal/api-keys", "personal/share-links", "personal/webhooks"]
      }
    ]
  }
}

Response fields:

Field Type Description
id integer User ID
name string Username
role string user. Use permissions for what you may do (Removed user fields).
email string Email address
emailConfirmed boolean Whether email has been verified
language string UI language code (2-char: en, fi, sv)
theme string Theme identifier: the organization’s theme, see Theme
profile object User preferences; see Profile under Key Concepts. For a share link, the sharer’s, without their tree preference.
phonenum string Verified phone number in international format
phonenum-unverified string Phone number pending verification (present only during verification)
lastlogin datetime | null Latest activity timestamp — the greater of last-credential-login-at and last-refresh-at. null if neither has occurred.
last-credential-login-at datetime | null Last time the user completed a username/password login (web or mobile). null if never.
last-refresh-at datetime | null Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started.
features object Paid features you can use. ai-helper is true when the AI helper is available to you; always false for a share link. You get the AI helper while your organization has it, unless your own setting is off. See Set user AI helper setting.
permissions object What you may do, and where. See Permissions below.
group-access-ends-at datetime When the organization’s access to the service ends. Present only when an end date has been set. From that moment on, all requests by the organization’s users, including login, token refresh and API keys, return 403 {"error": "group_suspended"}.

Users who manage users (org-admin, or building-admin at some location) additionally receive last-device-info (the device_info string from the most recent mobile session); others don’t. Get user has the full shape. The user management endpoints also count API key use, webhook deliveries and alarms sent in lastlogin; this endpoint does not.

Permissions

permissions lists what you may do, per scope. Use it to decide which actions to offer; the API still checks every request. role doesn’t tell what you may do.

Field Type Description
group object Organization-wide permissions, present only for an org-admin. roots are the paths of the organization’s top locations; permissions apply at and below each of them.
locations array Location permissions: location-id, path and permissions. Each applies at the location and everything below it.
share? boolean true for a share link. Its permissions apply at the shared location only.
share object Present only for a share link: location-id, the shared location, and mode, read or write. A read link views the location; a write link also writes notes and changes display settings, where the link’s owner may.
read-only? boolean true for a read-only API key. Only reading permissions are listed.

At an archived location only location/edit applies. Permission names:

Permission Scope Allows
location/view location Seeing the location and its measurements
location/rename location Renaming the location
location/edit location Editing, archiving and adding locations below it
location/manage-access location Managing who can access the location
location/display-settings location Graph settings and weather display
location/layout location Location image and blueprint coordinates
location/create-top-level organization Creating top-level locations
group/view organization The organization’s users, top locations and devices
device/configure location Configuring device settings
device/install location Installing, removing and moving devices
device/view-inventory organization All of the organization’s devices, installed or not
device/view-diagnostics organization Device problems
alarm/subscribe-quantity location Alarms on measured quantities
alarm/subscribe-status location Device-status alarms (no data, battery, signal)
alarm/edit-thresholds location Editing alarm thresholds
user/manage location Managing users
user/impersonate location Impersonating users
note/write location Writing notes
derived-quantity/manage location Managing derived quantities
survey/manage location Managing surveys
branding/manage organization Editing the organization’s theme
ai-helper/use organization Reserved; use features.ai-helper
personal/webhooks location Own webhooks
personal/api-keys location Own API keys
personal/share-links location Own share links

New permissions may be added; ignore names you don’t know.

Logout DRAFT

Method Location
DELETE token
Body Optional Datatype Description
refresh_token yes String Refresh token to revoke (mobile clients)

Clears the session cookie and logs the user out. Returns "logout" on success.

Mobile clients should include the refresh_token in the request body to revoke it server-side. If omitted, only the cookie is cleared (web logout behavior).

Example request (mobile)

{
  "refresh_token": "a1b2c3d4e5f6..."
}

Set language DRAFT

Method Location
PUT whoami/language
Body Optional Datatype
language no String (2-char code: en, fi, sv)

Set the UI language preference for the current user. The setting is synced — the same language applies on web and mobile.

Example request

{
  "language": "fi"
}

Success response: Returns the full user object (same as GET whoami).

Update settings DRAFT

Method Location
PUT whoami/settings
Body Optional Datatype
temperature-unit yes String (celsius, fahrenheit)
weight-unit yes String (kilogram, pounds)
pm-unit yes String (ug/m3, mg/m3)
wind-speed-unit yes String (m/s, km/h, mph)
precipitation-unit yes String (mm, inch)
pressure-unit yes String (hPa, inHg)
tree-locations yes Integer[] or null

Update display preferences: units, and the measurements tree preference tree-locations (see Profile). All fields are optional — only include fields you want to change; the others stay. Settings are synced across web and mobile. A value that can’t be stored answers 400.

Example request

{
  "temperature-unit": "celsius",
  "pressure-unit": "hPa"
}

Success response: Returns the full user object (same as GET whoami).

Set phone number DRAFT

Method Location
PUT whoami/phonenum
Body Optional Datatype
phonenum no String (international format, e.g. +358501234567)

Set or update the user’s phone number. Phone number must be in international format starting with +. Send an empty string "" to remove the phone number.

If the phone number is new, SMS verification is required. A 6-digit verification code is sent via SMS.

Example request

{
  "phonenum": "+358501234567"
}

Example response

{
  "verification-needed": true
}

If verification-needed is true, call the verify endpoint with the code received via SMS.

Verify phone number DRAFT

Method Location
PUT whoami/phonenum-verify
Body Optional Datatype
phone-num-verification-code no Integer (6-digit code, e.g. 123456)

Verify the phone number using the 6-digit code received via SMS. Returns 200 on success, 400 if the code is incorrect.

Example request

{
  "phone-num-verification-code": 123456
}

Request password reset DRAFT

Method Location
POST password/reset-request
Body Optional Datatype
email no String

Request a password reset link to be sent to the given email address. This is a public endpoint — no authentication required. The reset token is short-lived (1 hour) since the user is expected to act on the email immediately, and repeated requests for the same account are rate-limited. The response is always the same regardless of whether the email exists, to prevent user enumeration. For reset emails sent by someone who manages the user, with longer-lived tokens,, see POST password/send-reset-email under Admin APIs.

Example request

{
  "email": "user@example.com"
}

Example response

{
  "message": "If an account with that email exists, a password reset link has been sent."
}

Location Tree & Details DRAFT

Locations are organized hierarchically: site → building → floor → room. The tree API returns all locations the current user has access to, with their children nested recursively. See the Path and Tag sections under Admin APIs for details on the hierarchy model.

Get location tree DRAFT

Method Location
GET location/tree/my-locations

Returns all locations that the current user has access to, organized as a recursive tree structure. Each node contains its children, forming the complete hierarchy from top-level sites down to individual rooms. Archived locations are excluded from this response.

The tree starts from the locations selected for your user. If none of the selected locations is available to you (for example, none is selected, or all of them are archived), the tree contains every location you can view.

For a share link, the tree is the shared location alone, with no children: a share link reaches only that location.

Example response

[
  {
    "id": 5,
    "path": "campus.office",
    "name": "Office Building",
    "data": {},
    "uuid": "550e8400-e29b-41d4-a716-446655440001",
    "tag": "building",
    "parent": 1,
    "images": ["facade"],
    "children": [
      {
        "id": 10,
        "path": "campus.office.floor_1",
        "name": "Floor 1",
        "data": {},
        "uuid": "550e8400-e29b-41d4-a716-446655440002",
        "tag": "floor",
        "parent": 5,
        "images": ["blueprint"],
        "children": [
          {
            "id": 15,
            "path": "campus.office.floor_1.room_101",
            "name": "Meeting Room",
            "data": {},
            "uuid": "550e8400-e29b-41d4-a716-446655440003",
            "tag": "room",
            "parent": 10,
            "images": [null],
            "children": []
          }
        ]
      }
    ]
  }
]

Node fields:

Field Type Description
id integer Location ID
path string Hierarchical path (dot-separated)
name string Display name
data object Metadata (see Data section under Admin APIs)
uuid string UUID for external references
tag string Node type (building, staircase, floor, room)
parent integer Parent location ID (null for top-level)
images string[] Tags of uploaded images for this location (facade, blueprint). Returns [null] when no images exist. Use this to decide whether to call GET location/{id}/image?tag=facade — see the Location images subsection under Location Customization.
children array Child nodes (recursive)

Get location details DRAFT

Method Location
GET location/{id}
Parameter Optional Type Datatype
id no path Integer

Get details for a single location. Returns the same fields as a tree node but without parent and children. Returns 404 for archived locations.

Example response

{
  "id": 5,
  "path": "campus.office",
  "name": "Office Building",
  "data": {"address": "Example Street 1"},
  "uuid": "550e8400-e29b-41d4-a716-446655440001",
  "tag": "building",
  "images": ["facade"]
}

The images field has the same meaning as on the tree endpoint above — see the images row in the Node fields table.

List devices at location DRAFT

Method Location
GET location/{id}/devices
Parameter Optional Type Datatype
id no path Integer

List devices currently installed at the given location. Returns only the most recent installation per device.

product-name is the human-readable product name (e.g. "Loop One") resolved server-side from the first two characters of device-type. It is null when the prefix is not in the product catalog (e.g. legacy or third-party devices).

Example response

[
  {
    "description": "Air quality monitor",
    "device-id": "device-001",
    "device-type": "L103EWC0301",
    "product-name": "Loop One",
    "installation-time": "2024-01-15T10:30:00Z"
  }
]

Reading Observation Data DRAFT

The stable APIs above cover basic observation reads (history with 5000-row limit and latest values per device). This section documents additional ways to read observation data that are more suitable for graphing, analytics, and large time ranges.

Three approaches to reading observation data:

  1. 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.
  2. 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.
  3. 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:

Choosing an endpoint

What you want Use
A line chart of one quantity at a location, over any time range location/{id}/observations/downsampled
The same for one device you already know observation/read-v2/device/{device-id}/downsampled
Bucketed statistics (min/max/avg per hour or day), or a compact series for very long ranges location/{id}/aggregates/downsampled
Every individual observation — exports, custom processing, or client-side downsampling location/{id}/observations-v2/read or observation/read-v2/device/{device-id}
A small, recent slice with no pagination The stable Observation history API

Prefer a downsampled or aggregated read whenever the data is destined for a chart. One year of 10-minute data is roughly 52 000 points per device, and a chart a few hundred pixels wide cannot display more than a few hundred of them.

Incomplete reads. The downsampled endpoints read the raw observations behind your window before reducing them. If a range is so large or so dense that they cannot read all of it, the response carries "truncated": true and the series — along with any min/max/mean — describes only the part that was read. The field is absent otherwise, so its presence always means something. In practice this needs a range of several years on a location with an unusually high reporting rate; if you see it, the range wants the aggregated endpoint instead.

Sizing samples. For the downsampled endpoints, set samples to the chart’s width in pixels — asking for more points than the chart has pixels only costs bandwidth. The aggregated endpoint treats samples differently; see Aggregated observations below before choosing a value there.

Observation history V2 (cursor-based) DRAFT

Method Location
GET observation/read-v2/device/{device-id}
Parameter Optional Type Datatype
device-id no path String
start no query String (RFC3339)
end yes query String (RFC3339, defaults to now)
quantity yes query String
limit yes query Integer (1–20000, default 10000)
cursor yes query String (RFC3339, from previous response)

Cursor-based paginated read for large time ranges without downsampling. Unlike the stable V1 API (limited to 5000 rows), this API supports unlimited data retrieval by paginating through results.

start is inclusive, cursor and end are exclusive, so consecutive windows whose end is the next start return each observation once.

Usage: Make the first request with start (and optionally end and quantity). If has-more is true in the response, make another request with the returned cursor value to get the next page. Repeat until has-more is false.

Example response

{
  "observations": [
    {
      "dt": "2024-01-15T14:30:00Z",
      "quantity": "temperature",
      "value": 21.5,
      "unit": "C"
    },
    {
      "dt": "2024-01-15T14:30:00Z",
      "quantity": "co2",
      "value": 442,
      "unit": "ppm"
    }
  ],
  "has-more": true,
  "cursor": "2024-01-15T14:30:00Z"
}
Field Type Description
observations array Observation objects with ISO 8601 timestamps in dt field
has-more boolean true if more data is available
cursor string Timestamp to pass as cursor parameter for next page. null when no more data.

Note: The V2 API uses dt (ISO 8601 string) for timestamps, unlike V1 which uses timestamp (epoch). The cursor field is the timestamp of the last observation in the page — pass it back to continue.

Location observation history V2 (cursor-based) DRAFT

Method Location
GET location/{id}/observations-v2/read
Parameter Optional Type Datatype
id no path Integer
start no query String (RFC3339)
end yes query String (RFC3339, defaults to now)
quantity yes query String
limit yes query Integer (1–20000, default 10000)
cursor yes query String (RFC3339)

Same as device-level V2 streaming, but returns observations from all devices at the given location. Each observation includes a device-id field to identify the source device. Same pagination pattern — use cursor to get next pages.

Downsampled observations DRAFT

Method Location
GET location/{id}/observations/downsampled
Parameter Optional Type Datatype
id no path Integer
quantity no query String
start no query String (RFC3339)
end no query String (RFC3339)
samples yes query Integer (default 500, capped at 2000)

Returns observations downsampled to at most samples data points using the Largest-Triangle-Three-Buckets (LTTB) algorithm, which preserves the visual shape of the series while dramatically reducing the number of points. Values above 2000 are reduced to 2000. This is the recommended endpoint for drawing a location’s line chart. There is no limit on the length of the requested time range.

Alongside the points, the response carries min, max and mean for the window. These are computed over all observations in the range, before downsampling — so they remain accurate even at low samples values, and can be used to set a stable Y-axis or show a “peak was X” readout without a second request.

If no devices were installed at the location during the requested window — or none of them reported that quantity — all four fields come back empty (observations is [], the rest null). This is a normal result, not an error.

Example response

{
  "min": {
    "timestamp": "2024-01-15T04:20:00Z",
    "value": 18.1,
    "device-id": "device-001"
  },
  "max": {
    "timestamp": "2024-01-15T15:10:00Z",
    "value": 24.9,
    "device-id": "device-002"
  },
  "mean": 21.4,
  "observations": [
    {
      "timestamp": "2024-01-15T14:30:00Z",
      "value": 21.5,
      "device-id": "device-001"
    },
    {
      "timestamp": "2024-01-15T14:40:00Z",
      "value": 21.8,
      "device-id": "device-001"
    }
  ]
}
Field Type Description
min object The single lowest observation in the range, with its timestamp and source device (null if no data)
max object The single highest observation in the range (null if no data)
mean number Arithmetic mean of every observation in the range (null if no data)
observations array Downsampled data points, in ascending time order
observations[].timestamp string ISO 8601 timestamp
observations[].value number Numeric measurement value
observations[].device-id string Device that produced the point
truncated boolean Present and true only when the read could not cover the whole range; the points and the statistics then describe a subset of it

Downsampled observations, points only DRAFT

Method Location
GET location/{id}/observations/downsampled-v2
Parameter Optional Type Datatype
id no path Integer
quantity no query String
start no query String (RFC3339)
end no query String (RFC3339)
samples yes query Integer (default 500, capped at 2000)

A narrower variant of the endpoint above: same LTTB downsampling and the same handling of long ranges, but the response contains only the observations list — no window min, max or mean.

As with the endpoint above, the response carries "truncated": true if the read could not cover the whole range.

Note: The -v2 suffix denotes this different response shape, not a newer generation of the API. Prefer location/{id}/observations/downsampled when you also want the window statistics.

Example response

{
  "observations": [
    {
      "timestamp": "2024-01-15T14:30:00Z",
      "value": 21.5,
      "device-id": "device-001"
    },
    {
      "timestamp": "2024-01-15T14:40:00Z",
      "value": 21.8,
      "device-id": "device-001"
    }
  ]
}

Device downsampled observations DRAFT

Method Location
GET observation/read-v2/device/{device-id}/downsampled
Parameter Optional Type Datatype
device-id no path String
quantity no query String
start no query String (RFC3339)
end no query String (RFC3339)
samples yes query Integer (default 500, capped at 2000)

Returns LTTB-downsampled observations for a single device and a single quantity over the requested time window. Useful when you already know the device and want a chart-ready time series without first looking up its current location. There is no limit on the length of the requested range. Values of samples above 2000 are silently reduced to 2000.

You receive data only for the devices and time windows you have access to, as in Observation history. If the caller has no access for the device in the requested window, an empty observations list is returned.

Example response

{
  "observations": [
    { "timestamp": "2024-10-01T08:30:00Z", "value": 21.5 },
    { "timestamp": "2024-10-01T09:00:00Z", "value": 21.8 }
  ]
}
Field Type Description
observations array Downsampled data points, evenly distributed across the time range
observations[].timestamp string ISO 8601 timestamp
observations[].value number Numeric measurement value
truncated boolean Present and true only when the read could not cover the whole range

Aggregated observations DRAFT

Method Location
GET location/{id}/aggregates/downsampled
Parameter Optional Type Datatype
id no path Integer
quantity no query String
start no query String (RFC3339)
end no query String (RFC3339)
samples yes query Integer (default 500)

Returns time-bucketed aggregate statistics (min, max, average, count), optionally downsampled to samples points. Buckets are aligned to whole UTC hours or whole UTC days — note that a day bucket therefore starts at 00:00 UTC, not at local midnight.

samples has two effects here, which is worth understanding before choosing a value:

  1. It selects the bucket period. If the requested range is longer in hours than samples, day buckets are returned; otherwise hour buckets are. Unlike the downsampled endpoints, samples is not capped here — it has to be free to exceed 2000 so that long ranges can still ask for hour buckets.
  2. 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:

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:

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:

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:

  1. Per-location override. If quantities-v2.locations[<id>].location-thresholds[<quantity>].aqi-curve exists AND your user has “use location thresholds” enabled, use that single curve.
  2. Tier-specific default. Otherwise, read quantities-v2.quantities[<quantity>].default-thresholds[<tier>], where <tier> is the user’s selected strictness ("S1+", "S2+", or "S3+").
  3. Non-tiered default. If the quantity has no tiered curves (e.g., batt, rsrp), fall back to default-thresholds.default.
  4. No curve available. If none of the above exist (e.g., pm10*, or lafmax where 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.

Get analysis results DRAFT

Method Location
GET analysis-v2/location
Parameter Optional Type Datatype
path no query String (location path)
start no query String (RFC3339)
end yes query String (RFC3339)
tag yes query String (e.g. room)
quantity yes query String (filter for specific quantity)
threshold yes query String (user for custom thresholds)

Returns aggregated analysis results for locations under the given path. By default, returns the immediate children of the path. Use tag to filter to a specific level (e.g., tag=room returns all rooms under the path). Archived locations are excluded. A share link gets only the shared location: with tag, when it has that tag; without tag, an empty list.

Example response

[
  {
    "path": "campus.office.floor_1.room_101",
    "location": 15,
    "name": "Meeting Room",
    "analysis_in_progress": false,
    "analyses": [
      {
        "quantity": "temperature",
        "numeric": {
          "avg": {"value": 22.1},
          "min": {"value": 19.5, "dt": "2024-01-15T03:00:00Z"},
          "max": {"value": 24.8, "dt": "2024-01-15T14:30:00Z"}
        },
        "thresholds": {
          "S1+": {
            "total": {"percents": 100, "count": 144},
            "good": {"percents": 85, "count": 122},
            "warn": {"percents": 10, "count": 15},
            "alarm": {"percents": 5, "count": 7}
          }
        }
      },
      {
        "quantity": "co2",
        "numeric": {
          "avg": {"value": 580},
          "min": {"value": 410, "dt": "2024-01-15T06:00:00Z"},
          "max": {"value": 1150, "dt": "2024-01-15T13:00:00Z"}
        },
        "thresholds": {
          "S1+": {
            "total": {"percents": 100, "count": 144},
            "good": {"percents": 72, "count": 104},
            "warn": {"percents": 20, "count": 29},
            "alarm": {"percents": 8, "count": 11}
          }
        }
      }
    ]
  }
]

Response fields:

Field Type Description
analysis_in_progress boolean true if analysis is currently running for this location
analyses[].numeric.avg object Average value over the period
analyses[].numeric.min object Minimum value with timestamp
analyses[].numeric.max object Maximum value with timestamp
analyses[].thresholds object Quality breakdown per threshold standard. Keys are threshold identifiers (S1+, S2+, S3+ for Finnish standards, lt for user-defined).
thresholds.*.good object Percentage and count of time in “good” quality range
thresholds.*.warn object Percentage and count of time in “warning” range
thresholds.*.alarm object Percentage and count of time in “alarm” range
thresholds.*.total object Total data points analyzed

Alarms DRAFT

The alarm system monitors measurement data and sends notifications when values cross configured thresholds. Each user configures their own alarms — alarms are per-user, per-location, per-quantity.

Alarm configuration types:

Type Description Quantity
location_threshold Triggers when measurement crosses a threshold curve Required
location_no_data Triggers when no data received for the configured delay Must be null
device_problem Triggers when a device reports a hardware, software, environmental, or communication problem. These records are auto-generated from device telemetry — they are not user-created. Currently not creatable via the POST endpoint below; existing device_problem configs are provisioned by the platform operator. When one exists, its events and state are still returned by the GET endpoints. Note: battery level monitoring is not a device problem — use location_threshold with quantity: "batt" instead (see battery example below). Optional

Alarm lifecycle:

  1. idle — Normal state, no alarm condition
  2. pending — Threshold breached, waiting for delay minutes before activating
  3. activated — Delay expired, notification sent. Alarm stays activated until the condition clears and hysteresis minutes pass
  4. 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:

The curve belongs to the location, not to one user’s alarm: there is one per location and quantity, shared by every user’s alarm on that quantity there and by the user-defined thresholds (lt) in analysis. Set or delete it with Set location threshold and Delete location threshold. When it changes, every alarm on that quantity at the location gets a threshold_update or threshold_deleted event and returns to idle, so it is evaluated again against the new curve.

Without a curve, threshold alarms don’t fire. The UI and analysis then rate measurements with the quantity’s default thresholds, but alarms have no default to fall back to: a location_threshold alarm on a measured quantity stays silent until someone sets the location’s curve.

Breach condition:

The alarm is considered breached when the aqi-curve score for the current measurement drops below 2.0. This is the same alarm/warn boundary the classify algorithm in Analysis & Air Quality uses (< 2.0 = Alarm). When designing a custom curve, make sure the region you want to alarm on maps to a score below 2, and the safe region maps to a score ≥ 2. See the upper-threshold example below.

Get alarm events DRAFT

Method Location
GET alarm/events
Parameter Optional Type Datatype
start no query String (RFC3339)
end no query String (RFC3339)

Returns alarm events for the current user within the given time range. Maximum 5000 events, ordered by timestamp (newest first).

Only events of alarms at locations you can view now are returned. Alarms at archived locations, or at locations you no longer have access to, are left out; the alarm configurations are kept, and their events are returned again if access is restored.

Example response

[
  {
    "id": 1,
    "config-id": 42,
    "event-type": "activated",
    "timestamp": "2024-01-15T14:30:00Z",
    "reference-id": null,
    "event-text": null,
    "user-id": 1,
    "location-id": 15,
    "location-path": "campus.office.floor_1.room_101",
    "location-name": "Meeting Room",
    "quantity": "temperature",
    "config-type": "location_threshold"
  }
]
Field Type Description
id integer Event ID
config-id integer Alarm config this event belongs to
event-type string See event types table below
timestamp datetime Event time, truncated to seconds precision
reference-id integer References another alarm event by id (self-referential foreign key). For example, a notification event may reference the activation that triggered it. null when not applicable.
event-text string Optional free-form text. Populated for some event types — e.g. delay_update and hysteresis_update carry "value=<n>". null otherwise.
user-id integer Owner of the alarm config
location-id integer Location the alarm is attached to
location-path string Dotted location path
location-name string Display name of the location
quantity string Measurement quantity. null for location_no_data and device_problem configs.
config-type string Alarm config type (see table above)

Event types:

Event type Description
activated Alarm triggered after delay expired
pending Threshold breached, waiting for delay
deactivated Alarm cleared
email_notification Email notification sent
sms_notification SMS notification sent
threshold_update The location’s threshold curve was changed; every alarm on that quantity there gets one
threshold_deleted The location’s threshold curve was removed; every alarm on that quantity there gets one
delay_update Delay setting was changed
hysteresis_update Hysteresis setting was changed
alarm_enabled Alarm was enabled
alarm_disabled Alarm was disabled
email_enabled Email notifications enabled
email_disabled Email notifications disabled
sms_enabled SMS notifications enabled
sms_disabled SMS notifications disabled
device_problem_activated Device problem alarm triggered
device_problem_resolved Device problem resolved
acknowledgement Legacy value — present in the database enum but not produced by the current backend. May appear in historical data.

Get alarm state DRAFT

Method Location
GET alarm/state

Returns the current state of the alarms configured by the current user at locations you can view now. As with alarm events, alarms at archived locations or at locations you no longer have access to are left out.

Example response

[
  {
    "config-id": 42,
    "current-state": "idle",
    "enabled": true,
    "last-breached": null,
    "last-activated": null,
    "user-id": 1,
    "location-id": 15,
    "location-path": "campus.office.floor_1.room_101",
    "location-name": "Meeting Room",
    "quantity": "temperature",
    "config-type": "location_threshold"
  }
]
Field Type Description
current-state string Current alarm state: idle, pending, activated, or deactivated. Newly-created configs start at idle and transition through pending → activated → idle (or deactivated) as the alarm runs.
enabled boolean Whether the alarm is enabled
last-breached datetime When the threshold was last breached (null if never)
last-activated datetime When the alarm last triggered a notification (null if never)

Get alarm configuration DRAFT

Method Location
GET alarm/location/{id}/alarm-properties-v2
Parameter Optional Type Datatype
id no path Integer (location ID)
config-type yes query String (default location_threshold)
quantity yes query String

Get the current user’s alarm configuration for a specific location. Returns 404 if no alarm is configured.

Example response

{
  "delay-minutes": 30,
  "hysteresis-minutes": 15,
  "enabled": true,
  "medium": [1, 2],
  "aqi-curve": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]],
  "config-type": "location_threshold",
  "default-thresholds": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]]
}
Field Type Description
delay-minutes integer Minutes to wait before activating
hysteresis-minutes integer Minutes to wait before re-triggering
enabled boolean Whether the alarm is enabled
medium int[] Notification channels: [1]=email, [2]=SMS, [1,2]=both
aqi-curve array The location’s threshold curve, or null when none is set. Without a curve the alarm doesn’t fire.
config-type string Alarm type (see table above)
default-thresholds array System default thresholds for this quantity (read-only)

Set alarm configuration DRAFT

Method Location
POST alarm/location/{id}/alarm-properties-v2
Parameter Optional Type Datatype
id no path Integer (location ID)
Body Optional Datatype
delay no Integer (minutes, >= 0)
hysteresis no Integer (minutes, >= 0)
medium no Integer[] (notification channels)
config-type yes String: location_threshold (default) or location_no_data
quantity yes String
aqi-curve yes For batt, rsrp and snr, the alarm’s curve; null removes it. For a measured quantity, leave it out or send null: the location’s curve stays as it is, and you set it with Set location threshold. A curve sent for a measured quantity returns 400.
enabled yes Boolean (default true)

Create or update an alarm configuration. Only location_threshold and location_no_data configs can be created through this endpoint; passing device_problem returns 400 "Invalid config type".

Required role: viewer or a wider tier at the location, or alarm-subscriber on a guest-viewer grant in another organization. Quantity alarms need more; see below. Not open to share links.

A threshold alarm on a measured quantity (location_threshold on any quantity except the system quantities batt, rsrp and snr) needs quantity alarm subscriptions at the location: org-admin, building-admin, or a tier with the alarm-subscriber add-on. Without it the request returns 403, except when it only switches off your own existing alarm (enabled false). The save never changes or deletes the location’s curve. Alarms about the device itself need only a tier from viewer up at the location, or alarm-subscriber on a guest-viewer grant in another organization: no-data alarms (location_no_data) and thresholds on batt, rsrp or snr.

Example request

{
  "delay": 30,
  "hysteresis": 15,
  "medium": [1, 2],
  "config-type": "location_threshold",
  "quantity": "temperature",
  "enabled": true
}

Example response

{
  "id": 42,
  "delay-minutes": 30,
  "hysteresis-minutes": 15,
  "enabled": true,
  "medium": [1, 2],
  "quantity": "temperature",
  "config-type": "location_threshold",
  "aqi-curve": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]]
}
Field Type Description
id integer Alarm config ID (use this to correlate with events and state)
delay-minutes integer Configured delay
hysteresis-minutes integer Configured hysteresis
enabled boolean Whether the alarm is enabled
medium int[] Notification channels
quantity string Measurement quantity. null for location_no_data configs.
config-type string Alarm config type
aqi-curve array The location’s threshold curve, or null when none is set. null for location_no_data configs.

Note: the POST response does not include default-thresholds. Use the GET endpoint if you need to read the system default thresholds for a quantity.

Building a curve from limits

A threshold is usually described with up to four limits: alarm-low < goal-low ≤ goal-high < alarm-high. Each limit becomes one curve point, in ascending order of value:

Limit Score
alarm-low 2.0
goal-low 5.0
goal-high 5.0
alarm-high 2.0

Beyond each alarm limit, add one more point with score 0.0, so that values past the limit score below 2 and breach the alarm. Values between an alarm limit and its goal limit are classified as warn, and values between the goal limits as good.

Each alarm limit needs the goal limit on the same side: alarm-low with goal-low, alarm-high with goal-high. A one-sided threshold leaves out the pair on the other side.

Two-sided, temperature: alarm below 19 °C or above 26 °C, good from 20.5 °C to 23.5 °C:

"aqi-curve": [[18.0, 0.0], [19.0, 2.0], [20.5, 5.0], [23.5, 5.0], [26.0, 2.0], [27.0, 0.0]]

Upper side only, CO₂: alarm above 1200 ppm, good up to 750 ppm:

"aqi-curve": [[750.0, 5.0], [1200.0, 2.0], [1300.0, 0.0]]

Lower side only, battery: alarm below 20 %, good from 30 %:

"aqi-curve": [[10.0, 0.0], [20.0, 2.0], [30.0, 5.0]]

Example: upper-threshold alarm

Scenario: send an email when the temperature at a location stays above 23 °C for 45 minutes.

The default Finnish indoor thresholds are bell-shaped (too cold and too warm both count as breaches), so to alarm only on the upper side you supply a custom curve that drops below score 2 as soon as the value exceeds 23 °C:

{
  "delay": 45,
  "hysteresis": 0,
  "medium": [1],
  "config-type": "location_threshold",
  "quantity": "temperature",
  "aqi-curve": [[22.99, 5.0], [23.0, 2.0], [23.01, 0.0]]
}

How the curve works:

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:

Caution — device-specific timing: Different device types have different battery lifetimes and measurement intervals. While the API allows free configuration of batt thresholds and location_no_data delays, it is recommended to use the system default values unless you have a specific reason to override. For example, setting a 30-minute location_no_data delay 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:

Get location threshold DRAFT

Method Location
GET alarm/location/{id}/threshold
Parameter Optional Type Datatype
id no path Integer (location ID)
quantity no query String

The location’s threshold curve for a quantity, shared by every alarm on it there.

Required role: guest-viewer or a wider tier at the location, or a share link.

Example response

{
  "quantity": "co2",
  "aqi-curve": [[750.0, 5.0], [1200.0, 2.0], [1300.0, 0.0]],
  "default-thresholds": [[0.0, 5.0], [1050.0, 5.0], [1300.0, 0.0]],
  "subscribers": 3
}
Field Type Description
quantity string The quantity asked for
aqi-curve array The location’s curve, or null when none is set. Without a curve, threshold alarms on it don’t fire.
default-thresholds array The quantity’s default thresholds, which the UI and analysis use without a curve
subscribers integer Enabled alarms on this quantity at the location. Only for those who may edit thresholds there; deleting the curve stops these alarms.

Set location threshold DRAFT

Method Location
PUT alarm/location/{id}/threshold
Parameter Optional Type Datatype
id no path Integer (location ID)
Body Optional Datatype
quantity no String, a measured quantity
aqi-curve no Array of [measurement_value, quality_score] pairs

Sets the location’s threshold curve for a quantity. When the curve changes, every alarm on that quantity at the location gets a threshold_update event and returns to idle, and the location’s analysis is recalculated. Sending the curve already set changes nothing.

Required role: alarm threshold editing at the location: org-admin, building-admin, or the threshold-editor add-on. Not open to share links.

The response is the same as Get location threshold.

Example request

{
  "quantity": "co2",
  "aqi-curve": [[750.0, 5.0], [1200.0, 2.0], [1300.0, 0.0]]
}

Rules: - The curve follows the rules in Building a curve from limits above; anything else returns 400 {"error": "Bad Request", "message": "Invalid aqi-curve"}, and nothing is saved - batt, rsrp and snr return 400: their curve is saved with the alarm itself (Set alarm configuration)

Delete location threshold DRAFT

Method Location
DELETE alarm/location/{id}/threshold
Parameter Optional Type Datatype
id no path Integer (location ID)
quantity no query String, a measured quantity

Removes the location’s threshold curve for a quantity. Every alarm on that quantity at the location gets a threshold_deleted event, returns to idle, and doesn’t fire until a curve is set again; check subscribers in Get location threshold first. Deleting a curve that isn’t set changes nothing.

Required role: as for Set location threshold. batt, rsrp and snr return 400.

The response is the same as Get location threshold, with aqi-curve null.

Weather Data DRAFT

Weather data allows overlaying outdoor conditions (temperature, humidity, etc.) on indoor measurement graphs. Each location can be linked to a nearby weather station.

Search weather stations DRAFT

Method Location
GET weather-station/search
Parameter Optional Type Datatype
q no query String (search term)
limit yes query Integer

Search for weather stations by name.

Example response

{
  "stations": [
    {
      "id": 1,
      "name": "Helsinki-Vantaa lentoasema",
      "lat": 60.3172,
      "lon": 25.2458,
      "provider": "fmi",
      "external-id": "100953"
    }
  ]
}

Get weather observations DRAFT

Method Location
GET weather-station/{id}/observations
Parameter Optional Type Datatype
id no path Integer (station ID)
start no query String (RFC3339)
end no query String (RFC3339)

Get weather observation data from a station within the given time range.

Set weather station for location DRAFT

Method Location
PUT location/{id}/weather/config
Parameter Optional Type Datatype
id no path Integer (location ID)
Body Optional Datatype
station-id no Integer

Link a weather station to a location.

Required role: viewer or a wider tier at the location, or a read-write share link

Example request

{
  "station-id": 1
}

Remove weather station from location DRAFT

Method Location
DELETE location/{id}/weather/config
Parameter Optional Type Datatype
id no path Integer (location ID)

Remove the weather station link from a location. Returns 204 on success.

Required role: viewer or a wider tier at the location, or a read-write share link

Set weather display configuration DRAFT

Method Location
PUT location/{id}/weather/display
Parameter Optional Type Datatype
id no path Integer (location ID)
Body Optional Datatype
(free-form) no JSON object

Set display preferences for weather data at a location (e.g., which weather quantities to show on graphs).

Required role: viewer or a wider tier at the location, or a read-write share link

Location Customization DRAFT

These APIs allow users to customize how locations are displayed: images, floor plan coordinates, graph settings, and derived quantities.

Location images DRAFT

Locations can have two types of images: facade (building photo) and blueprint (floor plan). The image type is specified via the tag query parameter.

Required role: Reading: anyone who sees the location. Uploading and deleting: viewer or a wider tier at the location

Upload image

Method Location
PUT location/{id}/image
Parameter Optional Type Datatype
id no path Integer
tag yes query String (facade or blueprint, default facade)

Upload as multipart form data. Maximum image size is 10 MB.

Example response

{
  "filename": "office_blueprint.png",
  "size": 2048576
}

Download image

Method Location
GET location/{id}/image
Parameter Optional Type Datatype
id no path Integer
tag yes query String (facade or blueprint, default facade)

Returns the image as binary data with appropriate Content-Type header.

Delete image

Method Location
DELETE location/{id}/image
Parameter Optional Type Datatype
id no path Integer
tag yes query String (facade or blueprint, default facade)

Returns 204 on success.

Discovering available images. The images field returned by GET location/tree/my-locations and GET location/{id} lists the tags that have been uploaded for each location (e.g., ["facade"], ["facade", "blueprint"], or [null] if none). Check this field before calling GET to avoid wasted 404 round-trips.

Blueprint coordinates DRAFT

Position rooms on floor plan images. See blueprint-coordinates in the Data (Node Metadata) section for the data format.

Required role: Reading: anyone who sees the location. Setting and deleting: viewer or a wider tier at the location

Method Location Description
GET location/{id}/blueprint-coordinates List all coordinates for this location
PUT location/{id}/blueprint-coordinates Create or replace coordinates
DELETE location/{id}/blueprint-coordinates Delete all coordinates

PUT request/response example:

[
  {
    "parent-location": 10,
    "coords": {"x": 450, "y": 280}
  }
]

Derived quantities DRAFT

Custom calculated quantities. See derived-quantities in the Data (Node Metadata) section for the concept.

Required role: Reading: anyone who sees the location. Creating, changing and deleting: viewer or a wider tier at the location

Method Location Description
GET location/{id}/derived_quantity List all derived quantities
POST location/{id}/derived_quantity Create a new derived quantity
GET location/{id}/derived_quantity/{quantity-id} Get a specific derived quantity
PUT location/{id}/derived_quantity/{quantity-id} Update a derived quantity
DELETE location/{id}/derived_quantity/{quantity-id} Delete a derived quantity

POST/PUT request example:

{
  "name": "Comfort Index",
  "source": "temperature"
}

Graph settings DRAFT

Per-quantity Y-axis configuration. See graph-settings in the Data (Node Metadata) section for the data format.

Required role: viewer or a wider tier at the location, or a read-write share link

Method Location Description
PUT location/{id}/graph-settings Set graph settings for all quantities
DELETE location/{id}/graph-settings/{quantity} Remove graph settings for a specific quantity

PUT request example:

{
  "graph-settings": {
    "temperature": {"y-axis-min": 15.0, "y-axis-max": 30.0},
    "co2": {"y-axis-min": 400, "y-axis-max": 2000}
  }
}

Location Notes DRAFT

Notes are free-text annotations pinned to a location at a specific point in time. They can for example be surfaced as markers on the location measurements graph and are intended for human commentary that helps interpret the data later (e.g. “HVAC filter replaced”, “window left open during cleaning”, “tenant complaint about cold draft”).

Visibility model

Each note has a visibility of either "shared" or "private":

To prevent a user from hiding their own work-in-progress notes from the team without consent (and the inverse), changing visibility into or out of private is restricted to the original author. A non-author can edit a shared note’s body but not flip it to private; nor can they promote someone else’s private note to shared.

Audit fields

Each note records who created it (created-by, created-by-name, created-at) and, once edited, who last edited it (updated-by, updated-by-name, updated-at). Deletion is a soft-delete server-side; deleted notes are not returned by the list endpoint.

Body length

The body field is required, must be non-blank, and is limited to 4000 characters.

List notes DRAFT

Method Location
GET location/{id}/notes
Parameter Optional Type Datatype
id no path Integer (location id)
from no query ISO 8601 datetime
to no query ISO 8601 datetime

Returns all non-deleted notes for the location whose event-at falls within [from, to], filtered to those visible to the caller (all shared notes plus the caller’s own private notes). Ordered by event-at ascending.

Example response

[
  {
    "id": 42,
    "location-id": 1234,
    "event-at": "2026-04-15T08:30:00Z",
    "body": "HVAC filter replaced.",
    "visibility": "shared",
    "created-at": "2026-04-15T08:31:12Z",
    "created-by": 17,
    "created-by-name": "Maija Mehiläinen",
    "updated-at": "2026-04-15T08:35:02Z",
    "updated-by": 17,
    "updated-by-name": "Maija Mehiläinen"
  },
  {
    "id": 43,
    "location-id": 1234,
    "event-at": "2026-04-16T12:00:00Z",
    "body": "Tenant reported cold draft near window 3.",
    "visibility": "shared",
    "created-at": "2026-04-16T12:01:30Z",
    "created-by": 21,
    "created-by-name": "Pekka Pörhönen",
    "updated-at": null,
    "updated-by": null,
    "updated-by-name": null
  }
]

updated-at, updated-by, and updated-by-name are null until the note is edited.

Create note DRAFT

Method Location
POST location/{id}/notes
Parameter Optional Type Datatype
id no path Integer (location id)

Required role: viewer or a wider tier at the location, or a read-write share link

Request body

Field Type Notes
event-at ISO 8601 datetime Required. The point in time the note refers to.
body string Required. 1–4000 chars, non-blank.
visibility string Required. "shared" or "private".

Example request

{
  "event-at": "2026-04-15T08:30:00Z",
  "body": "HVAC filter replaced.",
  "visibility": "shared"
}

Returns 201 Created with the created note record (same shape as the list response element).

Update note DRAFT

Method Location
PATCH location/{id}/notes/{note-id}
Parameter Optional Type Datatype
id no path Integer (location id)
note-id no path Integer (note id)

Required role: viewer or a wider tier at the location, or a read-write share link

Request body

Field Type Notes
body string Required. 1–4000 chars, non-blank.
visibility string Required. "shared" or "private".

Returns 200 OK with the updated note record. The server sets updated-at, updated-by, and updated-by-name automatically.

Authorisation

Delete note DRAFT

Method Location
DELETE location/{id}/notes/{note-id}
Parameter Optional Type Datatype
id no path Integer (location id)
note-id no path Integer (note id)

Soft-deletes the note. Returns 204 No Content. The note will no longer be returned by the list endpoint after deletion.

Required role: viewer or a wider tier at the location, or a read-write share link

Authorisation

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:

Submit survey response DRAFT

Method Location
POST survey2/anonymous/location/{hashids-id}
Parameter Optional Type Datatype
hashids-id no path String
Body Optional Datatype
responses no Object (question-id → response)
datetime yes String (RFC3339)

Submit answers to a survey. Response keys are question indices (integers). Each response is either a choice response or a text response.

Example request

{
  "responses": {
    "0": {"choice-response": 1},
    "1": {"text-response": "The AC is too loud"}
  }
}

Returns 200 on success.

Internationalization DRAFT

Get translations DRAFT

Method Location
GET i18n/translation
Parameter Optional Type Datatype
language no query String (2-char code, e.g. en, fi)

Returns the UI translation strings for the given language as a nested JSON object. Authentication is optional — unauthenticated users get system default translations.

Example response

{
  "en": {
    "ui": {
      "dashboard": {
        "title": "Dashboard",
        "no-data": "No data available"
      }
    }
  }
}

Short URLs DRAFT

Create short URL DRAFT

Method Location
POST short-url
Body Optional Datatype
type no String (must be share)
target-url no String (valid URL)

Create a short URL for sharing. The short URL redirects to the target URL when visited.

Example request

{
  "type": "share",
  "target-url": "https://app.loopshore.com/share/view?path=campus.office"
}

Example response

{
  "id": 1,
  "code": "aBcDeFgH",
  "type": "share",
  "target-url": "https://app.loopshore.com/share/view?path=campus.office",
  "created-at": "2024-06-01T10:30:00Z",
  "visit-count": 0
}

The short URL is accessible at https://service.loopshore.com/s/{code}.

Admin APIs DRAFT

These APIs are used for managing users, devices, and locations, and need an administrative role. Each endpoint’s Required role line says which roles may call it; see User Roles for what each role gives.

Scope:

You act only within your organization, and only where your roles are granted. An org-admin covers the whole organization; the other roles cover the location they are granted at and everything below it.

When trying to access resources outside their scope, the API returns 403 (Forbidden) or 404 (Not Found) — the API does not reveal whether a resource exists if you don’t have access to it.

Admin API Authentication

These APIs use the same authentication methods as User APIs:

Key Concepts

Removed user fields

Access comes from grants: see User Roles, permissions in Get current user, and Get user grants. Three user fields from the older model were removed in version 0.66.0:

Field Was in Use instead
nodes Get current user, user responses, Create user, Update user permissions (and permissions.share.location-id for a share link); Get user grants and Replace user grants
profile.privileges Get current user, user responses, Create user, Update user The alarm-subscriber and threshold-editor grants
role value admin Get current user, user responses, Create user, Update user The org-admin grant; widest-role in List users

Theme

The theme field names the theme configuration that controls:

"theme": "loopshore"

The theme belongs to the organization: a user in one has its theme, which Loopshore sets, and reads return it.

Rules:

Use GET /api/theme-config/themes to see the themes you can configure.

Profile

The profile field stores user preferences as a JSON object. Its settings hold the display preferences that Update settings writes:

"profile": {
  "settings": {
    "temperature-unit": "celsius",
    "tree-locations": [12, 47]
  }
}
Field Type Description
settings object Display preferences: the units of Update settings, and tree-locations
settings.tree-locations int[] | null The tree preference: the locations whose subtrees the measurements tree shows, in this order. Empty, null or missing shows everything you can see. Entries you can’t see, archived and deleted locations are skipped; a preference with none left shows everything. It changes what is shown, not what you may access

Update user replaces the profile’s other keys with those sent and merges settings key by key, so settings not sent stay.

Note: The logo, title, and initial-view fields may appear in older data but are deprecated and not used by current UI.

Path (Location Hierarchy)

The path field defines a node’s position in the location hierarchy using dot-separated labels (PostgreSQL ltree type).

"path": "campus.building_a.floor_2.room_201"

Rules:

Examples:

Path Represents
helsinki Top-level site
helsinki.office_building Building within site
helsinki.office_building.floor_3 Floor within building
helsinki.office_building.floor_3.room_301 Room on floor

Tag (Node Type)

Valid node type values:

Tag Ordinal Description
site 10 Top-level grouping (campus, property)
building 20 Physical building
staircase 30 Staircase/entrance
floor 40 Floor level
room 50 Individual room/space

Hierarchy rule: A node’s tag ordinal should be greater than its parent’s. For example, a room (50) should not contain a building (20) as a child.

Data (Node Metadata)

Each node has a data field storing location-specific configuration. UI reads and updates these via dedicated APIs.

quantity-names

Custom display names for measurements. A map where keys are quantity identifiers and values are custom labels shown in UI instead of the default names.

{
  "temperature": "Supply Air Temp",
  "temperature_1": "Return Air Temp"
}

Use when the default quantity name is too generic (e.g., distinguishing “Supply Air Temp” from “Return Air Temp” when both use the generic “temperature” quantity).

This field is exposed in the quantities-v2 response under the name alternative-names (see Extended User APIs → Get quantity metadata).

quantities

Quantity visibility whitelist. An array of quantity identifiers that should be visible for this location. When set, only these quantities appear in graphs and dashboards.

["temperature", "humidity", "co2"]

Use when a device reports many quantities but only some are relevant. For example, a multi-sensor might report 20 quantities but only 3 matter for air quality monitoring.

Important: This is a UI-only setting. Backend APIs still return all data regardless of the whitelist.

Note: This data is stored in the node’s data field. To read or modify it via API, use the dedicated quantity whitelist endpoints (GET/PUT/DELETE /api/location/{id}/quantity-whitelist) rather than editing the node’s data field directly.

graph-settings

Per-quantity Y-axis configuration. A map where keys are quantity identifiers and values are objects with axis bounds.

{
  "temperature": {
    "y-axis-min": 15.0,
    "y-axis-max": 30.0
  },
  "co2": {
    "y-axis-min": 400,
    "y-axis-max": 2000
  }
}
Key Type Description
y-axis-min number Minimum value for Y-axis. If omitted, auto-scales.
y-axis-max number Maximum value for Y-axis. If omitted, auto-scales.

Users set these to maintain consistent graph scales instead of auto-scaling, making it easier to compare values across time.

blueprint-coordinates

Room position on floor plan images. An array of coordinate mappings that position this room on parent locations’ blueprint images.

[
  {
    "parent-location": 123,
    "coords": {"x": 450, "y": 280}
  }
]
Key Type Description
parent-location integer ID of the parent location whose blueprint image this coordinate refers to
coords.x integer Horizontal pixel position on the blueprint image
coords.y integer Vertical pixel position on the blueprint image

Used by floor plan visualization to show measurement points at their correct physical positions. A room can have coordinates for multiple parent blueprints (e.g., both floor-level and building-level views).

derived-quantities

Calculated measurements. An array of custom quantities computed from source measurements at this location.

[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Comfort Index",
    "source": "temperature",
    "formula": "linear",
    "parameters": {"slope": 1.5, "offset": -10}
  }
]
Key Type Description
id string (UUID) Unique identifier. Must remain stable for data continuity.
name string Display name shown in UI
source string Source quantity identifier to derive from
formula string Calculation type (e.g., “linear”, “threshold”)
parameters object Formula-specific parameters

Used to create custom metrics from raw sensor data, such as comfort indices or normalized values. The UUID is critical — changing it breaks historical data associations.

User Management APIs DRAFT

Send activation email DRAFT

Method Location
POST password/activate
Parameter Optional Type Datatype
name no query String
validDays yes query Integer

Send activation email to a user with password reset link. Used to onboard a newly-created user who has not yet set a password.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example request

POST /api/password/activate?name=newuser&validDays=7

Success response

{
  "message": "Activation email sent"
}

Send password reset email DRAFT

Method Location
POST password/send-reset-email
Parameter Optional Type Datatype
name no query String
validDays yes query Integer (default 14)

Email a password reset link to an existing user you manage. Intended for helping a user who has lost access or locked themselves out. The token is long-lived (14 days by default) so the user has time to react and so you can safely resend. Not rate-limited. Returns 404 for unknown users and users you don’t manage — enumeration defenses are unnecessary, since only users who manage users may call it.

Required role: org-admin, or building-admin at some location (see Get user grants)

Use one of the four password-related endpoints based on who initiates the flow:

Example request

POST /api/password/send-reset-email?name=alice&validDays=14

Success response

{
  "extra-msg": "Password reset email sent to alice@example.com"
}
Method Location
GET password/reset
Parameter Optional Type Datatype
name no query String
validDays yes query Integer

Get a password reset link for a user (does not send email). Useful when you need to deliver the link out-of-band (SMS, chat) rather than via email.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

{
  "reset-ui-url": "https://app.loopshore.com/reset-password?secret=abc123xyz"
}

List users DRAFT

Method Location
GET user

List all users in your scope: every user of your organization for an org-admin, and for a building-admin the users holding a role in their buildings. A listed user you can’t manage, because they also have access elsewhere or more rights than you, has manageable false: you can read them, and changing, suspending, deleting or impersonating them returns 403.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

[
  {
    "id": 1,
    "name": "facility@example.com",
    "role": "user",
    "email": "facility@example.com",
    "language": "en",
    "theme": "loopshore",
    "profile": {},
    "emailConfirmed": true,
    "lastlogin": "2024-06-02T00:00:00Z",
    "last-credential-login-at": "2024-05-28T08:00:00Z",
    "last-refresh-at": "2024-06-01T10:30:00Z",
    "last-device-info": "Pixel 9 Pro",
    "last-api-key-used-at": "2024-06-02T00:00:00Z",
    "last-webhook-delivered-at": null,
    "last-alarm-sent-at": "2024-05-30T06:12:00Z",
    "created-at": "2023-11-14T12:00:00Z",
    "created-at-estimated": true,
    "active": true,
    "password-reset-pending": false,
    "activation-email": {
      "sent-at": "2024-05-28T09:15:00Z",
      "sent-by-name": "admin",
      "status": "ok",
      "error-message": null
    },
    "password-reset-email": null,
    "manageable": true,
    "widest-role": "building-admin"
  }
]

Response fields:

Field Type Description
id integer User ID
name string Username
role string user. Use widest-role for what the user may do (Removed user fields).
email string Email address
language string UI language code
theme string Theme identifier
profile object User preferences; see Profile
emailConfirmed boolean Whether email has been verified
phonenum string Phone number
lastlogin datetime | null Last activity — the latest of last-credential-login-at, last-refresh-at, last-api-key-used-at, last-webhook-delivered-at and last-alarm-sent-at. null if none has occurred.
last-credential-login-at datetime | null Last time the user completed a username/password login (web or mobile). null if never.
last-refresh-at datetime | null Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started.
last-device-info string | null device_info string from the most recent mobile session. null if no mobile session has ever been started.
last-api-key-used-at datetime | null Last time any of the user’s API keys, share links included, authenticated a request. Updated at most once a day per key, so it can lag by up to a day. null if never recorded.
last-webhook-delivered-at datetime | null Last successful delivery of any of the user’s webhooks. Updated at most once a day per webhook. null if never recorded.
last-alarm-sent-at datetime | null Last time an alarm notification (email or SMS) was sent to the user. null if never.
created-at datetime | null When the user was created. null if unknown.
created-at-estimated boolean true when created-at is an estimate for a user created before creation dates were recorded: the user existed by then, judging by the earliest related record.
feature-overrides object The user’s own settings for paid features. {"ai-helper": false}: the AI helper is off for the user. No ai-helper key: the user follows the organization. See Set user AI helper setting.
suspended-at datetime When the user was suspended. Present only for suspended users. See Suspend user.
suspended-by integer ID of the user who suspended this user. Present only for suspended users, while that user exists.
suspended-by-name string Username of the user who suspended this user. Present only with suspended-by.
active boolean true once the user has set a password
password-reset-pending boolean true while a reset token is outstanding; flips to false when the user consumes the reset link
activation-email object | null Most recent activation email sent to this user, or null if none has been recorded
password-reset-email object | null Most recent password-reset email sent to this user, or null if none has been recorded
manageable boolean Whether you may change, suspend, delete or impersonate the user. false for a user you see but don’t manage.
widest-role string The widest role the user holds: org-admin, building-admin, operator, viewer, or guest-viewer when that is all they hold. Add-on roles aren’t counted. Omitted for a user without access.

The activation-email and password-reset-email objects (when non-null) contain the most recent send attempt for the matching email_type:

Field Type Description
sent-at datetime When the send was attempted
sent-by-name string | null Username of the user who triggered the send; null for self-service (public reset-request)
status string ok, error, or dev_mode
error-message string | null Short error description when status = error; otherwise null

Create user DRAFT

Method Location
POST user
Body Optional Datatype
name no String
email yes String
password yes String
role yes String
language yes String
theme yes String
profile yes JSON object
grants yes JSON array

Create a new user. If password is omitted, user must reset via activation email.

Give the user’s roles in grants. role is user, or may be left out. nodes, profile.privileges and any other role answer 400 (Removed user fields).

Required role: org-admin, or building-admin at some location (see Get user grants)

grants gives the new user’s grants, as Replace user grants takes them, stored together with the user. If any grant is refused (400 or 403, as there), the user isn’t created. If you manage users in some locations only, the new user needs a role in at least one of them, since you see only the users who hold one: otherwise 400.

Example request

{
  "name": "maintenance@example.com",
  "email": "maintenance@example.com",
  "language": "fi",
  "grants": [
    {"role": "operator", "location": 25}
  ]
}

Success response

{
  "id": 15,
  "name": "maintenance@example.com"
}

Typical workflow:

  1. Create user with POST /api/user
  2. Send activation email with POST /api/password/activate?name=maintenance@example.com
  3. User clicks link in email and sets their password

Get user DRAFT

Method Location
GET user/{user-id}
Parameter Optional Type Datatype
user-id no path Integer

Get user details by ID.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

{
  "id": 15,
  "name": "user@example.com",
  "email": "user@example.com",
  "role": "user",
  "language": "en",
  "theme": "loopshore",
  "profile": {},
  "emailConfirmed": false,
  "phonenum": "+358501234567",
  "lastlogin": "2024-05-15T14:20:00Z",
  "last-credential-login-at": "2024-05-15T14:20:00Z",
  "last-refresh-at": "2024-05-14T09:05:00Z",
  "last-device-info": "iPhone 15 Pro",
  "last-api-key-used-at": null,
  "last-webhook-delivered-at": null,
  "last-alarm-sent-at": "2024-05-10T07:45:00Z",
  "created-at": "2024-04-02T09:30:00Z",
  "created-at-estimated": false,
  "active": true,
  "password-reset-pending": false
}

Response fields:

Field Type Description
id integer User ID
name string Username
role string user. Use Get user grants for what the user may do (Removed user fields).
email string Email address
language string UI language code
theme string Theme identifier
profile object User preferences; see Profile
emailConfirmed boolean Whether email has been verified
phonenum string Phone number
lastlogin datetime | null Last activity — the latest of last-credential-login-at, last-refresh-at, last-api-key-used-at, last-webhook-delivered-at and last-alarm-sent-at. null if none has occurred.
last-credential-login-at datetime | null Last time the user completed a username/password login (web or mobile). null if never.
last-refresh-at datetime | null Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started.
last-device-info string | null device_info string from the most recent mobile session. null if no mobile session has ever been started.
last-api-key-used-at datetime | null Last time any of the user’s API keys, share links included, authenticated a request. Updated at most once a day per key, so it can lag by up to a day. null if never recorded.
last-webhook-delivered-at datetime | null Last successful delivery of any of the user’s webhooks. Updated at most once a day per webhook. null if never recorded.
last-alarm-sent-at datetime | null Last time an alarm notification (email or SMS) was sent to the user. null if never.
created-at datetime | null When the user was created. null if unknown.
created-at-estimated boolean true when created-at is an estimate for a user created before creation dates were recorded: the user existed by then, judging by the earliest related record.
feature-overrides object The user’s own settings for paid features. {"ai-helper": false}: the AI helper is off for the user. No ai-helper key: the user follows the organization. See Set user AI helper setting.
suspended-at datetime When the user was suspended. Present only for suspended users. See Suspend user.
suspended-by integer ID of the user who suspended this user. Present only for suspended users, while that user exists.
suspended-by-name string Username of the user who suspended this user. Present only with suspended-by.
active boolean true once the user has set a password
password-reset-pending boolean true while a reset token is outstanding; flips to false when the user consumes the reset link

The activation-email and password-reset-email audit fields are only included on List users.

Update user DRAFT

Method Location
PUT user/{user-id}
Parameter Optional Type Datatype
user-id no path Integer
Body Optional Datatype
name yes String
email yes String
password yes String
role yes String
language yes String
theme yes String
profile yes JSON object
phonenum yes String

Update user details. Only include fields you want to change. Change the user’s roles with Replace user grants: this endpoint doesn’t take grants.

Required role: org-admin, or building-admin at some location (see Get user grants)

role may repeat the user’s current one, which changes nothing. nodes, profile.privileges and a different role answer 400 (Removed user fields). A profile replaces the user’s other profile keys and merges settings key by key (see Profile); null changes nothing.

Returns 403 for a user you see but may not manage, and 404 for a user you can’t see.

Example request

{
  "language": "fi",
  "phonenum": "+358501234567"
}

Delete user DRAFT

Method Location
DELETE user/{user-id}
Parameter Optional Type Datatype
user-id no path Integer

Delete a user. Returns 204 on success.

Required role: org-admin, or building-admin at some location (see Get user grants)

Deleting a user also deletes their API keys and share links, webhooks, alarm subscriptions and history, and dashboards. To stop an account without losing any of these, suspend it instead. Get user impact lists what would be deleted, with the last use of each item.

Suspend user DRAFT

Method Location
PUT user/{user-id}/suspension
Parameter Optional Type Datatype
user-id no path Integer

Suspend a user without deleting anything. No request body. While suspended, the user:

Required role: org-admin, or building-admin at some location (see Get user grants)

You can suspend the users you manage (see Replace user grants), but not yourself (400); others return 403. Suspending an already suspended user keeps the original suspended-at and suspended-by.

Success response

The user, same shape as Get user, with suspended-at, suspended-by and suspended-by-name set.

Error responses

Status Description
400 Tried to suspend yourself
403 The user is outside your group

Unsuspend user DRAFT

Method Location
DELETE user/{user-id}/suspension
Parameter Optional Type Datatype
user-id no path Integer

Restore a suspended user. Login, existing sessions and refresh tokens, API keys, share links, webhooks and alarm notifications all work again as before. Returns the user, same shape as Get user, without the suspension fields. Same access rules and errors as Suspend user.

Required role: org-admin, or building-admin at some location (see Get user grants)

Get user impact DRAFT

Method Location
GET user/{user-id}/impact
Parameter Optional Type Datatype
user-id no path Integer

What suspending or deleting a user affects, with the last use of each item, so you can check whether the account is still in use. Suspension stops the share links, API keys, webhooks and alarm subscriptions; deletion also removes the dashboards and the group membership. Last-use times for API keys, share links and webhooks are updated at most once a day.

Required role: org-admin, or building-admin at some location (see Get user grants)

You can read every user on your List users, yourself included; others return 403.

Success response

{
  "user-id": 42,
  "group": {"id": 7, "name": "Example Oy"},
  "share-links": [
    {"id": 311, "key-name": "Lobby screen", "purpose": "sharer", "read-only": false,
     "created-at": "2025-11-03T08:12:44Z", "last-used-at": "2026-10-05T06:30:12Z",
     "location-id": 1203, "location-name": "Lobby"}
  ],
  "api-keys": [
    {"id": 298, "key-name": "BMS integration", "purpose": "all", "read-only": true,
     "created-at": "2025-06-17T10:01:09Z", "last-used-at": null}
  ],
  "webhooks": [
    {"id": "0b6f4c1e-5d2a-4f7e-9a51-3c8e2d7f1a90", "host": "hooks.example.com",
     "description": "Building automation", "scope": "all", "device-id": null,
     "last-delivered-at": "2026-10-06T04:00:00Z"}
  ],
  "alarm-subscriptions": [
    {"id": 88, "location-id": 1207, "location-name": "Meeting room 2", "quantity": "co2",
     "config-type": "location_threshold", "enabled": true, "last-sent-at": "2026-09-28T13:45:00Z"}
  ],
  "dashboards": [
    {"id": "6a1d9e2b-0c4f-4b8a-8f3e-2d5c7b9a1e64", "name": "Overview", "visibility": "private",
     "updated-at": "2026-08-14T09:20:31Z"}
  ]
}
Field Type Description
group object The user’s group (id, name), or null
share-links array Share links: key-name, purpose, read-only, created-at, last-used-at (null = never used), and the shared location-id and location-name
api-keys array The user’s other API keys, same fields without the location
webhooks array Webhook subscriptions with last-delivered-at, the last successful delivery. Only the URL’s host is returned
alarm-subscriptions array Alarm subscriptions with location-id, location-name, quantity, config-type, enabled and last-sent-at, when an alarm notification was last sent
dashboards array Dashboards with name, visibility and updated-at

Error responses

Status Description
403 The user is outside your group
404 No such user

Set user AI helper setting DRAFT

Method Location
PUT user/{user-id}/feature-override
Parameter Optional Type Datatype
user-id no path Integer
Body Optional Datatype Description
feature no String ai-helper
enabled yes Boolean | null false switches the AI helper off for the user. Omit it, or send null, to have the user follow the organization again.

The AI helper is a paid feature of the organization. While your organization has it, every user gets it unless their own setting is off. An org-admin can switch it off for the users they manage, and back to following the organization, only while the organization has the AI helper. A building-admin gets 403.

Required role: org-admin

Example request

{
  "feature": "ai-helper",
  "enabled": false
}

Example response

The user’s settings after the change:

{
  "ai-helper": false
}

Error responses

Status Description
400 Unknown feature
403 The user is outside your group, your organization doesn’t have the AI helper, or enabled is true

Get user operation stats DRAFT

Method Location
GET user/{user-id}/operation-stats
Parameter Optional Type Datatype
user-id no path Integer
operation-type yes query String
start yes query String (RFC3339)
end yes query String (RFC3339)

Get throttled operation statistics for a user (SMS sending, password resets, etc.).

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

{
  "total": [
    {"opertype": "sms", "total-count": 12},
    {"opertype": "password-reset", "total-count": 3}
  ],
  "data": [
    {"hourbucket": "2024-05-15T14:00:00Z", "count": 2, "opertype": "sms"}
  ]
}

Check username availability DRAFT

Method Location
GET check-username
Parameter Optional Type Datatype
username no query String

Check if a username is available for new user creation.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

{
  "available": true
}

Impersonate user DRAFT

Method Location
POST imposter
Body Optional Datatype
name no String

Login as another user for support purposes. You may impersonate a user you manage: every role they hold lies where you manage users and gives nothing you lack there, so an org-admin may impersonate another org-admin of the organization. A refusal is 403 for a user on your List users, 404 for others.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example request

{
  "name": "user@example.com"
}

Success response

Sets a session cookie for the target user, same as regular login.

{
  "user": "user@example.com",
  "ts": "2024-06-01T10:30:00Z"
}

Impersonating a suspended user returns 409 {"error": "user_suspended"} and leaves your own session untouched.

While impersonating you act fully as the target user: everything done in the session is recorded as theirs. The session ends when your own account is suspended (403 {"error": "user_suspended"}), deleted or no longer allowed to impersonate anyone (401).

Users with access DRAFT

Method Location
GET access/users-with-access

Who can view each location where you manage users, by location ID. Only users of your own organization are listed. via says whether the access comes from the location or a location above it (location), from organization-wide access (group), or both. role is the user’s widest tier there (see Get user grants). Archived locations, and locations nobody can view, are left out. Returns 403 if you can’t manage users anywhere.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

{
  "locations": {
    "12": [
      {"id": 7, "name": "admin@example.com", "via": ["group"], "role": "org-admin"},
      {"id": 9, "name": "user@example.com", "via": ["location"], "role": "viewer"}
    ]
  }
}

Get user grants DRAFT

Method Location
GET user/{user-id}/grants
Parameter Optional Type Datatype
user-id no path Integer

A user’s grants: what they may do, and where. A grant is a role at a location, which also covers every location below it, or org-admin on your organization. Each grant says who granted it (granted-by, a user ID, null when unknown) and when, and whether you may add or remove it (editable).

Required role: org-admin, or building-admin at some location (see Get user grants)

Role Kind What it gives
guest-viewer Tier Viewing a location in another organization. Set by Loopshore; always read-only here.
viewer Tier Viewing and naming locations, notes, display settings, status alarms, personal API keys, webhooks and share links.
operator Tier Viewer, plus installing, removing and configuring devices.
building-admin Tier Operator, plus editing locations below, managing users, quantity alarms and alarm thresholds, and surveys.
org-admin Tier Everything in your organization. Granted on the organization (group), not a location.
alarm-subscriber Add-on Quantity alarm subscriptions. On a guest-viewer grant, quantity and device-status alarm subscriptions; set there by Loopshore.
threshold-editor Add-on Editing alarm thresholds.
device-configurator Add-on Configuring devices.

An add-on gives nothing without a tier at or above its location.

Readable for the users you see in List users. Returns 404 for other users.

Example response

{
  "grants": [
    {"role": "viewer", "location": 12, "editable": true,
     "granted-by": 7, "granted-at": "2026-10-19T08:15:00Z"},
    {"role": "alarm-subscriber", "location": 31, "editable": true,
     "granted-by": 7, "granted-at": "2026-10-19T08:15:00Z"}
  ]
}

Replace user grants DRAFT

Method Location
PUT user/{user-id}/grants
Parameter Optional Type Datatype
user-id no path Integer
Body Optional Datatype Description
grants no Array The user’s grants after the change: each a role with a location ID, or org-admin with your organization’s group ID

Replaces the user’s grants with the set given. Only grants you may add or remove change: those the response of Get user grants marks editable. Others stay whether the set lists them or not. Unchanged grants keep who granted them and when. Returns the user’s grants as Get user grants does.

Required role: org-admin, or building-admin at some location (see Get user grants)

You may add or remove a grant only where you manage users and hold every permission its role gives. An organization admin grants any role in the organization; a building-admin grants roles up to building-admin within their locations.

Errors:

Nothing is stored when the request is refused.

Example request

{
  "grants": [
    {"role": "viewer", "location": 12},
    {"role": "operator", "location": 31},
    {"role": "alarm-subscriber", "location": 31}
  ]
}

Node/Location Management APIs DRAFT

List nodes DRAFT

Method Location
GET node

List the locations you manage or install devices at, with every location below them, archived ones included: your organization’s locations for an org-admin, a building-admin’s buildings, an operator’s locations. Returns a flat list.

Required role: org-admin, or building-admin or operator at some location (see Get user grants)

Example response

[
  {
    "id": 1,
    "path": "helsinki",
    "name": "Helsinki Campus",
    "data": {"address": "Example Street 1"},
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "tag": "site",
    "images": [null],
    "archived_at": null
  },
  {
    "id": 5,
    "path": "helsinki.office",
    "name": "Office Building",
    "data": {},
    "uuid": "550e8400-e29b-41d4-a716-446655440001",
    "tag": "building",
    "images": ["blueprint", "facade"],
    "archived_at": "2026-04-10T08:30:00Z"
  }
]

Response fields:

Field Type Description
id integer Node ID (used in nodes array for user access)
path string Hierarchical path
name string Display name
data object Metadata JSON
uuid string UUID for external system references
tag string Node type
images string[] Image tags attached to this location
archived_at string|null ISO 8601 timestamp when the node was archived, or null if active

To build a tree structure, use the path field - child nodes have paths that start with their parent’s path.

Create node DRAFT

Method Location
POST node
Body Optional Datatype
path no String
name no String
tag yes String
data yes JSON object

Create a new node. The parent path must already exist.

Required role: org-admin, or building-admin above the new location

Example request

{
  "path": "helsinki.office.floor_2",
  "name": "Floor 2",
  "tag": "floor",
  "data": {
    "area_sqm": 450
  }
}

Success response

{
  "id": 26,
  "path": "helsinki.office.floor_2",
  "name": "Floor 2",
  "data": {"area_sqm": 450},
  "tag": "floor",
  "uuid": "550e8400-e29b-41d4-a716-446655440003"
}

Get node DRAFT

Method Location
GET node/{id}
Parameter Optional Type Datatype
id no path Integer

Get node details by ID.

Required role: org-admin, or building-admin at the location or above it

Example response

{
  "id": 25,
  "path": "helsinki.office.floor_1",
  "name": "Floor 1",
  "data": {"area_sqm": 500},
  "tag": "floor",
  "uuid": "550e8400-e29b-41d4-a716-446655440002"
}

Update node DRAFT

Method Location
PATCH node/{id}
Parameter Optional Type Datatype
id no path Integer
Body Optional Datatype
name yes String
path yes String
tag yes String
data yes JSON object

Update node properties. Changing path moves the node in the hierarchy.

Required role: org-admin, or building-admin at the location or above it; see Who may change what below

data is merged into the node’s stored data: the keys you send replace those keys, and the other keys stay. A key sent as null is removed. data.address is a string of at most 500 characters: anything else returns 400.

Who may change what: you need location edit at the node (org-admin, or building-admin at the node or above it). Moving the node, and changing its data beyond address, also need location edit above it, as do deleting and archiving it. So the location a role is given at changes its own name and address, and its structure is changed from the level above. Otherwise 403.

Move restrictions: Moving a node across an archive boundary is not allowed. You cannot move an active node under an archived parent, or an archived node under an active parent. The API returns HTTP 400 with a descriptive message if the move would violate this rule.

Example request

{
  "name": "Floor 1 - Main Office",
  "data": {"address": "Example Street 1"}
}

Delete node DRAFT

Method Location
DELETE node/{id}
Parameter Optional Type Datatype
id no path Integer
Body Optional Datatype
path no String

Permanently delete a node and all its children. The path must match the node’s actual path (serves as confirmation).

Required role: org-admin for a location below the organization’s top locations, or building-admin above the location

Warning: This is a cascading delete. All child nodes, device installations, alarms, and images will be permanently deleted. Consider using Archive node instead for a reversible operation.

Archive node DRAFT

Method Location
POST node/archive/{id}
Parameter Optional Type Datatype
id no path Integer
Body Optional Datatype
path no String

Archive a node and all its descendants (soft-delete). The path must match the node’s actual path (serves as confirmation). Archived locations are hidden from everyone but those who edit them (org-admin, or building-admin above them), stop generating analyses and alarms, and do not trigger webhooks or public surveys. All data is preserved and can be restored by unarchiving.

Required role: org-admin for a location below the organization’s top locations, or building-admin above the location

Returns 409 Conflict if the node is already archived.

Example request

{
  "path": "helsinki.office"
}

Example response

[
  {"id": 5, "path": "helsinki.office", "name": "Office Building"},
  {"id": 25, "path": "helsinki.office.floor_1", "name": "Floor 1"},
  {"id": 26, "path": "helsinki.office.floor_2", "name": "Floor 2"}
]

The response lists all nodes that were archived (the target node and its descendants).

Effects of archiving:

Unarchive node DRAFT

Method Location
POST node/unarchive/{id}
Parameter Optional Type Datatype
id no path Integer

Restore an archived node and all its archived descendants. The node becomes visible to users again and resumes normal operation (analyses, alarms, webhooks, surveys).

Required role: org-admin for a location below the organization’s top locations, or building-admin above the location

Returns 400 if the node is not archived, or if the parent node is still archived (unarchiving must be done top-down).

Example response

[
  {"id": 5, "path": "helsinki.office", "name": "Office Building"},
  {"id": 25, "path": "helsinki.office.floor_1", "name": "Floor 1"},
  {"id": 26, "path": "helsinki.office.floor_2", "name": "Floor 2"}
]

The response lists all nodes that were unarchived.

Quantity Whitelist APIs DRAFT

These endpoints manage the data.quantities field of a location node. See the quantities section under Data (Node Metadata) for details on what this setting controls.

Get quantity whitelist DRAFT

Method Location
GET location/{id}/quantity-whitelist
Parameter Optional Type Datatype
id no path Integer

Get the quantity whitelist for a location. Returns 404 if no whitelist is set (using system defaults).

Required role: org-admin, or building-admin at the location or above it

Example response

["temperature", "humidity", "co2", "pm2p5"]

Set quantity whitelist DRAFT

Method Location
PUT location/{id}/quantity-whitelist
Parameter Optional Type Datatype
id no path Integer
Body Optional Datatype
(array) no String[]

Set or update the quantity whitelist for a location.

Required role: org-admin, or building-admin at the location or above it

Example request

["temperature", "humidity", "co2"]

Note: This is a UI-only setting. Backend APIs still return all data; the frontend filters what to display.

Remove quantity whitelist DRAFT

Method Location
DELETE location/{id}/quantity-whitelist
Parameter Optional Type Datatype
id no path Integer

Remove the quantity whitelist, reverting to system defaults. Returns 204 on success.

Required role: org-admin, or building-admin at the location or above it

Device Management APIs DRAFT

About device-id. A device’s device-id (equivalently its endpoint in 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:

Operations:

Operation API Effect
Install POST device/{id}/installation Creates a new installation record
Remove POST device/{id}/removal Sets removal-time on the active installation
Edit PUT installations/{id} Updates times on an existing record
Delete DELETE installations/{id} Erases the record entirely

Example: device-001 installation history

installation-id location installation-time removal-time
150 25 2024-01-01T09:00:00Z 2024-03-15T14:00:00Z
151 30 2024-03-15T14:00:00Z 2024-06-01T10:00:00Z
152 30 2024-09-01T08:00:00Z null

Reading this: device-001 was at location 25 from Jan to Mar, at location 30 from Mar to Jun, uninstalled from Jun to Sep, then reinstalled at location 30 where it remains.

This model allows querying which device was at a specific location at any point in time (useful for historical data analysis).

Product Codes

The device-type field typically contains a product code — an 11-character structured identifier that encodes device specifications for Loopshore devices. For other vendors’ devices, this field may contain different identifiers.

List devices DRAFT

Method Location
GET device

List the devices your organization holds now, held in the past, or is due to hold.

If you install or configure devices at some locations only (for example as a building-admin, an operator or with device-configurator), the list has the devices your organization holds now that are installed now at those locations or below them.

For a device your organization doesn’t hold now, location, location-name, installation-time and path are null and notable-flags is omitted, even if the device is installed for another organization.

Required role: org-admin, or operator, building-admin or device-configurator at some location (see Get user grants)

Query parameters:

Parameter Optional Type Datatype
include-notable-flags yes query Boolean (default false)

When include-notable-flags=true, each device that has one or more notable settings carries a notable-flags array (see Notable device settings). Flags are computed from the device’s current configuration; devices with no notable settings simply omit the field.

Example response

[
  {
    "device-id": "device-001",
    "device-type": "LD03EWC0301",
    "product-name": "Loop Delta",
    "description": "Air quality monitor",
    "location": 25,
    "location-name": "Room 201",
    "installation-time": "2024-01-15T10:30:00Z",
    "path": "campus.building_a.floor_2.room_201",
    "group-access": [
      {
        "group-id": 14,
        "group-name": "Helsinki office",
        "start-time": "2024-01-01T00:00:00Z",
        "end-time": null
      }
    ],
    "notable-flags": [
      { "key": "notable.pressure.capillary", "tone": "info" },
      { "key": "notable.dense_interval", "tone": "warn", "params": { "interval": 120 } }
    ]
  },
  {
    "device-id": "device-002",
    "device-type": "L003EWC0301",
    "product-name": "Loop Zero",
    "description": "Temperature sensor",
    "location": null,
    "location-name": null,
    "installation-time": null,
    "path": null,
    "group-access": []
  }
]

Response fields:

Field Type Description
device-id string Unique device identifier
device-type string Product code
product-name string Human-readable product name resolved server-side from the first two characters of device-type (e.g. "Loop One", "Loop Delta"). null when the device-type prefix is not in the product catalog.
description string Human-readable description
location integer Current location ID (null if not installed, or if your organization doesn’t hold the device now). A device with a future removal-time is still considered installed.
location-name string Name of the current location (null when location is)
installation-time datetime When the current installation started (null if not installed, or if your organization doesn’t hold the device now)
path string Location path for current installation (null if not installed, or if your organization doesn’t hold the device now)
group-access array The periods during which the device is assigned to your group: past, current and future, newest first. Each entry has group-id, group-name, start-time and end-time (null = no end). Assignments to other groups are not shown.
notable-flags array Present only with include-notable-flags=true, and only for devices with at least one notable setting. See Notable device settings.
Notable device settings DRAFT

notable-flags highlights device-configuration settings worth an operator’s attention — for example a Loop Delta measuring through a capillary tube, a denser-than-normal measurement interval, or power save switched off. The backend derives them from the device’s current configuration and returns only the localization key, a display tone, and any interpolation parameters — never a pre-rendered string — so every client localizes identically.

The field appears in three places: the device list above (with include-notable-flags=true), the location-level device object of last-quantities for everyone who sees the measurements except a share link, and the device configuration read for everyone who may read the configuration.

Each entry has:

Field Type Description
key string Localization key identifying the setting; localize client-side.
tone string Display severity hint: info, warn, or notice.
params object Optional interpolation values for the localized message (omitted when none).

Possible key values:

key Meaning tone params
notable.pressure.capillary Pressure-difference device measuring through a capillary tube (compensation algorithm engaged) info —
notable.pressure.free_flow Pressure-difference device measuring through a direct / free-flow tube info —
notable.dense_interval Measurement interval denser than 5 min while the device is not adaptively sending warn interval (seconds)
notable.power_save_off Device power save disabled notice —

The set of keys may grow over time; clients should tolerate unknown keys (fall back to the raw key or ignore the entry).

List install candidates DRAFT

Method Location
GET device-inventory/install-candidates

List the devices you can install: those your organization holds now that have no active installation, ordered by device-id. A device with a future removal-time is still installed and isn’t listed.

Required role: org-admin, or building-admin or operator at some location (see Get user grants)

Example response

[
  {
    "device-id": "device-002",
    "device-type": "L003EWC0301",
    "product-name": "Loop Zero",
    "description": "Temperature sensor",
    "location": null,
    "location-name": null,
    "installation-time": null,
    "path": null
  }
]

The fields are those of List devices, without group-access and notable-flags. location, location-name, installation-time and path are always null.

Install device DRAFT

Method Location
POST device/{device-id}/installation
Parameter Optional Type Datatype
device-id no path String
Body Optional Datatype Description
location no Integer Location ID to install at
installation-time no String (RFC3339) When the installation starts
removal-time yes String (RFC3339) When the installation ends (omit for open-ended)
end-current-installation? yes Boolean If true and the device already has an open installation, atomically close it (its removal-time is set to the new installation-time) before creating the new installation. Returns 400 if the existing installation started after the new installation-time.

Create a new installation record for a device at a location. Returns 400 if the device already has an installation that overlaps with the given time range, or if another device is already installed at the location during that time.

Required role: org-admin, or building-admin or operator at the location (see Get user grants)

Your organization must hold the device at installation-time, which may be in the past. Returns 403 if it didn’t.

If removal-time is omitted, the installation is open-ended (the device stays installed indefinitely until explicitly removed). If removal-time is provided, the installation is bounded to the period [installation-time, removal-time).

Pass end-current-installation?: true to move a device from its current room in one call. Moving a device that is installed now also needs one of the roles above where it is installed now. An install that doesn’t end a current installation is checked at the target location only.

Returns 404 for a device or location outside what you can see, and 403 for ones you can see but may not install or move there. Besides the devices installed at locations you see, you see your organization’s install candidates if you install devices at some location; an org-admin sees all of the organization’s devices.

Example request (open-ended)

{
  "location": 25,
  "installation-time": "2024-06-01T09:00:00Z"
}

Example request (bounded)

{
  "location": 25,
  "installation-time": "2024-06-01T09:00:00Z",
  "removal-time": "2024-09-01T08:00:00Z"
}

Example request (move from current room)

{
  "location": 30,
  "installation-time": "2024-09-01T08:00:00Z",
  "end-current-installation?": true
}

Success response

{
  "installation-id": 150,
  "device-id": "device-001",
  "location": 25,
  "installation-time": "2024-06-01T09:00:00Z",
  "removal-time": null
}

Error response (overlap)

{
  "error": "Device already has an installation that overlaps with this time. Remove the existing installation first."
}

Remove device DRAFT

Method Location
POST device/{device-id}/removal
Parameter Optional Type Datatype
device-id no path String
Body Optional Datatype Description
removal-time no String (RFC3339) When the device is removed

Remove a device from its current location by setting removal-time on its active (open-ended) installation. Returns 404 for a device that doesn’t exist or is outside what you can see, 403 for one you can see but may not remove at that time, and 400 if the device is not currently installed anywhere.

Required role: org-admin, or building-admin or operator at the device’s current location (see Get user grants)

The removal-time can be in the past or the future. A future removal-time schedules the removal — the device remains “currently installed” until that time passes.

Example request

{
  "removal-time": "2024-06-15T16:00:00Z"
}

Success response

{
  "installation-id": 150,
  "device-id": "device-001",
  "location": 25,
  "installation-time": "2024-06-01T09:00:00Z",
  "removal-time": "2024-06-15T16:00:00Z"
}

The response is the updated installation record with removal-time now set.

List installations DRAFT

Method Location
GET installations

List the installation records that started while your organization held the device, ordered by installation time (newest first).

Required role: org-admin, or building-admin, operator or device-configurator at some location (see Get user grants)

If you install or configure devices at some locations only, the list has only the installations at those locations or below them.

Example response

[
  {
    "installation-id": 152,
    "device-id": "device-001",
    "location": 30,
    "installation-time": "2024-09-01T08:00:00Z",
    "removal-time": null
  },
  {
    "installation-id": 151,
    "device-id": "device-001",
    "location": 30,
    "installation-time": "2024-03-15T14:00:00Z",
    "removal-time": "2024-06-01T10:00:00Z"
  },
  {
    "installation-id": 150,
    "device-id": "device-001",
    "location": 25,
    "installation-time": "2024-01-01T09:00:00Z",
    "removal-time": "2024-03-15T14:00:00Z"
  }
]

Response fields:

Field Type Description
installation-id integer Unique installation record ID
device-id string Device identifier
location integer Location ID
installation-time datetime When the installation started
removal-time datetime When the installation ended (null = still installed or scheduled)

List device installations DRAFT

Method Location
GET device/{device-id}/installations
Parameter Optional Type Datatype
device-id no path String

List all installation records for a specific device, ordered by installation time (newest first). Includes the location path for each installation.

Required role: org-admin, or building-admin, operator or device-configurator at some location (see Get user grants)

If you install or configure devices at some locations only, the list has only the device’s installations at those locations or below them.

Example response

[
  {
    "installation-id": 152,
    "device-id": "device-001",
    "location": 30,
    "installation-time": "2024-09-01T08:00:00Z",
    "removal-time": null,
    "path": "campus.building_a.floor_2.room_201"
  },
  {
    "installation-id": 150,
    "device-id": "device-001",
    "location": 25,
    "installation-time": "2024-01-01T09:00:00Z",
    "removal-time": "2024-03-15T14:00:00Z",
    "path": "campus.building_a.floor_1.room_101"
  }
]

Update installation DRAFT

Method Location
PUT installations/{installation-id}
Parameter Optional Type Datatype
installation-id no path Integer
Body Optional Datatype Description
installation-time yes String (RFC3339) New start time
removal-time yes String (RFC3339) or null New end time (null to clear, making the installation open-ended)

Update the times on an existing installation record. Only include fields you want to change. Returns 400 if the update would cause an overlap with another installation (same device at two locations, or two devices at the same location). Returns 403 if a new installation-time falls before your organization had the device.

Required role: org-admin, or building-admin or operator at the installation’s location (see Get user grants)

Example request — set removal time

{
  "removal-time": "2024-12-31T23:59:59Z"
}

Example request — clear removal time (make open-ended)

{
  "removal-time": null
}

Success response

Returns the updated installation record.

{
  "installation-id": 150,
  "device-id": "device-001",
  "location": 25,
  "installation-time": "2024-06-01T09:00:00Z",
  "removal-time": "2024-12-31T23:59:59Z"
}

Delete installation DRAFT

Method Location
DELETE installations/{installation-id}
Parameter Optional Type Datatype
installation-id no path Integer

Delete an installation record entirely. After deletion, it is as if the device was never installed at that location during that time period. Measurement data is not affected — only the installation record is removed.

Required role: org-admin, or building-admin or operator at the installation’s location (see Get user grants)

Returns 200 with the deleted record on success.

Survey Management APIs DRAFT

These APIs manage feedback surveys — view which surveys are assigned to which locations, view results, and manage assignments.

List survey summaries DRAFT

Method Location
GET survey2/summary

List all surveys with summary information.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

[
  {
    "id": 42,
    "page-count": 3,
    "question-count": 8,
    "first-page-title": {
      "en": "Indoor Air Quality Feedback",
      "fi": "Sisäilmapalaute"
    }
  }
]

List survey-location assignments DRAFT

Method Location
GET survey2/locations

List which surveys are assigned to the locations you can view. Archived locations are excluded.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

[
  {
    "survey": 42,
    "location": 15,
    "location-name": "Meeting Room",
    "location-path": "campus.office.floor_1.room_101",
    "datetime": "2024-01-15T10:30:00Z"
  }
]

Assign survey to location DRAFT

Method Location
PUT survey2/location/{id}
Parameter Optional Type Datatype
id no path Integer (location ID)
Body Optional Datatype
survey-id no Integer

Assign a survey to a location, enabling anonymous feedback collection at that location.

Required role: org-admin, or building-admin at the location or above it

Example request

{
  "survey-id": 42
}

Remove survey from location DRAFT

Method Location
DELETE survey2/location/{id}
Parameter Optional Type Datatype
id no path Integer (location ID)

Remove a survey assignment from a location. Returns 204 on success.

Required role: org-admin, or building-admin at the location or above it

Get survey results DRAFT

Method Location
GET survey2/results
Parameter Optional Type Datatype
start no query String (RFC3339)
end no query String (RFC3339)

Get all survey responses within the given time range, across all locations where you manage surveys. Archived locations are excluded. Results include both choice and free-text responses.

Required role: org-admin, or building-admin at some location (see Get user grants)

Example response

[
  {
    "datetime": "2024-01-15T10:30:00Z",
    "page-title": {"en": "Indoor Air Quality", "fi": "Sisäilma"},
    "question-text": {"en": "How is the temperature?", "fi": "Miten koet lämpötilan?"},
    "response-type": "choice",
    "location": 15,
    "path": "campus.office.floor_1.room_101",
    "choice-text": {"en": "Good", "fi": "Hyvä"}
  },
  {
    "datetime": "2024-01-15T10:35:00Z",
    "page-title": {"en": "Other Feedback", "fi": "Muu palaute"},
    "question-text": {"en": "Other comments?", "fi": "Muuta kommentoitavaa?"},
    "response-type": "free",
    "location": 15,
    "path": "campus.office.floor_1.room_101",
    "text-response": "The AC is too loud in the afternoon"
  }
]

For response-type: "choice", the response includes choice-text. For response-type: "free", it includes text-response.

LWM2M Device Configuration APIs DRAFT

LWM2M (Lightweight M2M) is the protocol used for device management. These APIs allow viewing device status and configuration.

Device configuration format

Device configuration is a JSON object stored as-is in the database and passed to the device firmware. The same format is used when reading (GET) and writing (PUT) device configuration.

Configuration fields:

Field Type Description
e integer Enable bits bitmask (0–2147483647). Controls which sensors and features are active. See enable bit positions below.
i integer Measurement interval in seconds (10–86400). How often the device takes measurements.
si integer Particle measurement interval in seconds (10–86400). Separate interval for particle sensor measurements.
md integer Microphone duty cycle percentage (0–100).
c string Command to execute (optional). See command strings below. The device processes the command once; it is not automatically cleared from the stored configuration, and the device re-runs it whenever the configuration version is bumped. A client that reads a configuration, modifies it and writes it back must therefore clear c itself unless it intends the command to run again.
ct integer Calibration target (optional). Used with the cco2 command to specify the reference CO2 ppm value.
cal array Software calibration data (optional). An array of per-quantity calibration entries — see Calibration data format below. Managed by the calibration UI. Omit only if the device has never been calibrated; once cal has appeared in any earlier config version, every subsequent config should round-trip it unchanged. To explicitly clear all calibrations, send "cal": []. Treat the contents as opaque: clients should round-trip the value unchanged unless they are the calibration UI itself.

Example configuration:

{
  "e": 2147483391,
  "i": 300,
  "si": 900,
  "md": 10,
  "cal": [
    {"q": 30, "p": [[0.0, 0.0], [100.0, 99.5]]},
    {"q": 4,  "p": [[400.0, 410.0], [1000.0, 998.0]]}
  ]
}
Calibration data format

The cal field is a JSON array. Each element is an object describing the calibration of a single measurement quantity:

Field Type Description
q integer Quantity being calibrated, as the device’s measurement resource ID (e.g. 4 = co2, 30 = pressure_diff, 36 = pn0p5).
p array Calibration points, each a [raw, calibrated] pair sorted by raw ascending.

Example cal value:

[
  {"q": 30, "p": [[0.0, 0.0], [100.0, 99.5]]},
  {"q": 4,  "p": [[400.0, 410.0], [1000.0, 998.0]]}
]

Calibratable quantities:

q Quantity q Quantity
1 temperature 27 tvoc_density
2 humidity 30 pressure_diff
3 tvoc 31 pressure_diff_min
4 co2 32 pressure_diff_max
5 pm1p0 33 co
6 pm2p5 36 pn0p5
7 pm4p0 37 pn1p0
8 pm10p0 38 pn2p5
9 pressure 39 pn4p0
10 light 40 pn0p3
150 temperature_1

Limits: at most 12 entries and 5 points per entry. Devices with older firmware apply only the first 3 entries. The whole configuration must fit in 990 bytes, see Write device configuration.

Setting and clearing calibration on the device:

Wire form on PUT Effect on the device
cal field omitted entirely Device keeps whatever calibration it currently has (historical firmware behaviour).
"cal": [] (empty array) Device clears all software calibrations.
"cal": [ ... ] (non-empty) Device replaces all software calibrations with the supplied entries. There is no per-quantity merge; entries you do not include are dropped.

Note that only the third row replaces calibrations on the device. Omitting cal does not clear it — but it does cause the stored config in the server’s history to no longer reflect what the device is actually running, which is misleading and easy to mishandle in any subsequent read-modify-write. Treat omission as “I have never seen cal in any prior version of this device’s config”; if you have seen it, send it back.

Note for client implementors. Because cal is opaque passthrough data and its internal shape may evolve, clients that are not the calibration UI should deserialize it as a generic JSON value (e.g. JsonElement / any / serde_json::Value) rather than a strongly-typed model. Using a strict object type will fail to deserialize the array and — if the failure path falls back to writing a default config that includes "cal": [] — silently destroy the device’s calibration. See the warning under Write device configuration.

Enable bit positions

The e field is a 31-bit integer bitmask. Each bit controls one sensor or feature. A bit value of 1 means enabled, except for bits marked as inverted logic where 1 means disabled/OFF.

The Schema key column shows the corresponding key in the config-schema endpoint’s enable_bits response. Use the config-schema to determine which bits a product type supports before setting them.

Bit Schema key Description Notes
0 microphone Microphone sensor
1 particles Particulate matter sensor
2 gps GPS Inverted logic — bit 1 = GPS OFF
3 temperature Temperature sensor
4 humidity Humidity sensor
5 co2 CO2 sensor
6 tvoc TVOC PPB sensor
7 light Light sensor
8 pressure Pressure sensor
9 motion Motion sensor
10 battery Battery monitoring
11 extra_temp External temperature probe
12 debug_stats Debug statistics Deprecated
13 nw_stats Network statistics (RSRP, SNR) Deprecated
14 pressure_diff_low_power Differential pressure — low power mode
15 pressure_diff_accurate Differential pressure — accurate mode
16 pressure_diff_free_flow Differential pressure — free flow pipe
17 datalogger Store measurements offline, send when network available
18 network_backoff 24h backoff when network unavailable (only if datalogger disabled)
19 co2_abc CO2 automatic baseline calibration (toward outdoor CO2 level)
20 tvoc_index_off TVOC index sensor (VOC Index 1–500) Inverted logic — bit 1 = TVOC index OFF
21 spl_pause_during_fan Pause sound level measurement while the particle sensor fan runs Defaults to 1 (enabled)
22 particle_counts_off Particle counts (pn0p5, pn1p0, pn2p5, pn4p0, pn0p3) and typical particle size (tps) Inverted logic — bit 1 = particle counts OFF. Needs bit 1.
23–26 — Unused
27 tvoc_debug_not_active TVOC debug Inverted logic — bit 1 = OFF. Deprecated
28 zero_adaptive_sending Adaptive sending (Zero hardware only)
29 power_save Power saving when not on USB power
30 default_cat_m Prefer Cat-M over NB-IoT Requires reboot

About bit 21 (spl_pause_during_fan). The particle sensor fan sits a few centimetres from the microphone, so sound level measured while the fan spins reports the device’s own fan noise rather than the room. With the bit set, the device measures sound level only while the fan is stopped. Devices report lower and more accurate sound levels than before, which appears as a step change in historical trends rather than a fault. When the measurement interval is short enough that the fan runs continuously (below 65 s, or below 20 s in power save), no sound level is reported at all for as long as that configuration lasts — this is intended behaviour and the device raises no problem for it. The bit is set by default; clear it only if you specifically want the old behaviour.

Default value: 2147483647 (0x7FFFFFFF — all 31 bits set to 1). This enables all sensors and features, with inverted-logic bits in their OFF state.

Example: To enable only microphone (bit 0), temperature (bit 3), and humidity (bit 4), keep all inverted-logic bits set (GPS, TVOC index, particle counts and TVOC debug off), and keep the fan pause enabled (bit 21):

e = bit 0 + bit 3 + bit 4 + bit 2 + bit 20 + bit 21 + bit 22 + bit 27 = 1 + 8 + 16 + 4 + 1048576 + 2097152 + 4194304 + 134217728 = 141557789

When building e from scratch like this, remember the bits that are features rather than sensors: leaving bit 21 clear silently restores the old behaviour of measuring sound level through the fan noise.

Command strings

The c field accepts the following command strings. The Schema key column shows the corresponding key in the config-schema endpoint’s commands response.

Wire value Schema key Description
"r" reboot Reboot device
"b" locate_sound Play the locate sound so the device can be found. The device plays a rising chime for about a minute, starting at its next check-in rather than immediately; moving or shaking the device silences it. A device below approximately 3.1 V battery ignores the command, with no feedback. Sound level measurement is suspended while the sound plays, so the measurement cycle that overlaps it reports no noise values.
"cco2" calibrate_co2 Calibrate CO2 sensor. Set ct to the reference ppm value.
"cr" reset_config Reset to default configuration (deprecated). Other config changes in the same write are ignored.
"pd" post_debug Post debug data (deprecated)
"rt" reset_tvoc Reset TVOC sensor (deprecated)

List LWM2M devices DRAFT

Method Location
GET lwm2m/device-v2

List all LWM2M devices with their latest configuration and firmware information. A building-admin, operator or device-configurator without org-admin gets the devices their organization holds now that are installed now at their locations, with all fields.

Required role: org-admin, or building-admin, operator or device-configurator at some location (see Get user grants)

Example response

[
  {
    "id": 42,
    "endpoint": "123456789012345",
    "device-id": "123456789012345",
    "hardware": "L103EWC0301",
    "config-version": 5,
    "config-datetime": "2024-06-01T10:30:00Z",
    "config": { "e": 2147483391, "i": 300, "si": 900, "md": 10 },
    "comment": "Updated measurement interval",
    "firmware-version": "2.0.0",
    "firmware-semver": "2.0.0",
    "firmware-description": "Adds CO2 ABC support",
    "target-hw": "L103EWC0301",
    "firmware-uri": "https://firmware.example.com/L103EWC0301/app-2.0.0.bin",
    "firmware-datetime": "2024-06-02T09:15:00Z",
    "firmware-status": "confirmed",
    "firmware-error": null
  }
]

Response fields:

Field Type Description
id integer Internal database ID
endpoint string Device endpoint identifier
device-id string Same as endpoint
hardware string Hardware/product code
config-version integer | null Current configuration version (null if no configuration has been written)
config-datetime datetime | null When the current configuration was written
config object | null The current configuration itself, in the Device configuration format
comment string | null Comment supplied with the current config version
firmware-version string | null Version label of the firmware currently associated with the device
firmware-semver string | null Semantic version of the current firmware
firmware-description string | null Description / changelog of the current firmware
target-hw string | null Hardware target of the current firmware (matches the device’s hardware field when an assignment exists)
firmware-uri string | null URI of the firmware binary
firmware-datetime datetime | null Timestamp of the latest firmware status row for this device
firmware-status string | null One of configured (assigned, awaiting install), confirmed (installed successfully), or error (install failed)
firmware-error integer | null Device-side error code from the last firmware status report (populated only when firmware-status is "error")

Visibility note. An org-admin gets every device their organization holds now, held in the past or is due to hold; for a device the organization doesn’t hold now, the config-*, config, comment, target-hw, and all firmware-* fields are omitted from that device’s row entirely (not set to null). Clients should treat all of these fields as both nullable and optional.

Get device configuration DRAFT

Method Location
GET lwm2m/device/endpoint/{endpoint}/config
Parameter Optional Type Datatype
endpoint no path String

Get the current configuration for a specific device. The response wraps the device configuration in an envelope with metadata.

Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)

Response fields:

Field Type Description
version integer Configuration version, incremented on every write
config object The device configuration itself, in the Device configuration format
comment string | null Comment supplied with this config version (see write endpoint for guidance)
datetime datetime When this version was written
notable-flags array Present for every caller who may read the configuration, and only when the device has at least one notable setting. See Notable device settings.

Example response

{
  "version": 5,
  "config": {
    "e": 2147483391,
    "i": 120,
    "si": 900,
    "md": 10
  },
  "comment": "Denser measurement interval",
  "datetime": "2024-06-01T10:30:00Z",
  "notable-flags": [
    { "key": "notable.dense_interval", "tone": "warn", "params": { "interval": 120 } }
  ]
}

Get configuration history DRAFT

Method Location
GET lwm2m/device/endpoint/{endpoint}/config/history
Parameter Optional Type Datatype
endpoint no path String

Get the configuration change history for a device. Returns a list of configuration versions, newest first, in the same envelope shape as Get device configuration.

Required role: org-admin

Example response

[
  {
    "version": 5,
    "config": { "e": 2147483391, "i": 300, "si": 900, "md": 10 },
    "comment": "Increased measurement interval",
    "datetime": "2024-06-01T10:30:00Z"
  },
  {
    "version": 4,
    "config": { "e": 2147483391, "i": 60, "si": 900, "md": 10 },
    "comment": "Initial configuration",
    "datetime": "2024-05-15T08:00:00Z"
  }
]

Write device configuration DRAFT

Method Location
PUT lwm2m/device/endpoint/{endpoint}/config
Parameter Optional Type Datatype
endpoint no path String

Write a new configuration version for a device. The device will receive the new config on its next check-in. The version number is assigned by the server and returned in the response — clients must not supply it.

Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)

Warning — read-modify-write. The submitted config object 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). cal has its own on-device semantics, summarised in the table under Calibration data format:

  • Omitting cal does 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. If cal has 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 cal as 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. Treat cal as opaque (JsonElement / any / serde_json::Value) so the GET response always parses successfully and cal round-trips unchanged on PUT.

Request body fields:

Field Type Required Description
config object yes The device configuration in the Device configuration format. Stored as-is — see warning above about preserving fields like cal.
comment string | null no Free-text comment describing this change. Recommended to either uniquely describe the configuration (e.g. what changed and why) or leave it out entirely — generic repeated comments add noise to the history without adding information.
expected-version integer | null no Optimistic-concurrency guard. When given, the write is rejected with 409 unless it matches the device’s current config version at write time (checked atomically, not read-then-write) — use this to detect “someone else changed this device’s config since I last read it” instead of silently overwriting a concurrent change. 0 means “no config has been written yet”. Omit or send null to skip the check and write unconditionally (the pre-existing behavior).

config.i is validated against the device’s config-schema interval.min/interval.max when a schema is configured for the device’s product type; a value outside that range is rejected with 400. Devices with no config-schema on record are unaffected (permissive, matching pre-0.32.0 behavior).

config must fit in 990 bytes as the device receives it: compact JSON in UTF-8, with numbers as the server stores them (2.0E7 counts as 20000000). A longer config is rejected with 400 and not stored.

Example request body

{
  "config": {
    "e": 2147483391,
    "i": 300,
    "si": 900,
    "md": 10
  },
  "comment": "Increased measurement interval to reduce battery drain"
}

Example response

{
  "version": 6
}

The returned version is the version number assigned to the newly written configuration.

Error responses:

Status Body Description
400 Forbidden User does not have access to this device
400 {"error": "...", "field": "..."} config.i (or si/md, where applicable) is outside the range declared by the device’s config-schema
400 {"error": "...", "field": "config"} config is longer than 990 bytes
409 {"error": "Config version conflict", "current-version": integer} expected-version was given and didn’t match the current version — re-read the config and retry

Device Live View DRAFT

Temporarily drops a device’s measurement interval to its config-schema-declared minimum for near-real-time monitoring (e.g. watching a live differential-pressure reading while adjusting an HVAC system), then automatically reverts the device back to its prior configuration after a bounded time.

Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)

Ground truth that shapes this feature: devices use PSM (Power Save Mode), not eDRX — there is no way to wake a device from the server side. A config change, whether starting the live session or reverting it, only takes effect at the device’s next self-initiated check-in. There is no way to force this from software; the fastest way to get a device to pick up a change immediately is to physically power-cycle it (slide its power switch off, then on). If the device loses power or coverage while a session is active, the server-side revert is written immediately, but the device won’t actually apply it until it reconnects — possibly much later. This is an accepted limitation, not a bug to report.

A live-view session is a single shared resource per device — there is no per-viewer/participant tracking. Whoever calls start while a session is already active for that device just gets the existing session back; any caller with access to the device may heartbeat or stop it, regardless of who started it. Use the started-by-me response field if the client wants to distinguish “I started this” from “someone else did”.

A session ends one of three ways:

Idle timeout and the background sweep’s own cadence (~5 minutes) compound, so an abandoned session can take up to ~8 minutes to actually revert — expected, not a bug.

Start a live-view session DRAFT
Method Location
POST lwm2m/device/endpoint/{endpoint}/live-view/start
Parameter Optional Type Datatype
endpoint no path String

Starts a new live-view session, writing a new config version with i set to the device’s schema-declared minimum interval. If a session is already active for this device, returns that session unchanged — no new session or config write.

Response fields:

Field Type Description
id integer Session id, used in the heartbeat/stop paths below
device-id string The device’s endpoint
status string active, reverted, or superseded
prior-config-version integer The config version in effect before this session started; restored on revert
live-config-version integer The config version this session wrote
started-at datetime
expires-at datetime Idle deadline — extended by each heartbeat call
hard-deadline-at datetime started-at + max session duration; never extended
resolved-at datetime | null When the session ended, or null while active
started-by-me boolean Whether the calling user is the one who started this session
live-interval-seconds integer | null The i value in effect while this session is active
next-check-in-eta datetime | null Best-effort estimate of when the device will next check in and pick up the pending config. Not guaranteed — there is no true LWM2M registration timestamp available server-side, this is derived from the device’s last observed activity. Treat it purely as a passive-fallback hint for a “this activates automatically within about n minutes” message, not a promise.

Example response

{
  "id": 42,
  "device-id": "abc123",
  "status": "active",
  "prior-config-version": 5,
  "live-config-version": 6,
  "started-at": "2026-08-10T09:00:00Z",
  "expires-at": "2026-08-10T09:03:00Z",
  "hard-deadline-at": "2026-08-10T11:00:00Z",
  "resolved-at": null,
  "started-by-me": true,
  "live-interval-seconds": 10,
  "next-check-in-eta": "2026-08-10T09:05:00Z"
}

Error responses:

Status Body Description
400 Forbidden User does not have access to this device
409 {"error": "..."} Device is lost/destroyed (lifecycle-blocked), has no config-schema interval floor configured, has no existing config to branch off of, or the config write failed — retry
Heartbeat a live-view session DRAFT
Method Location
POST lwm2m/device/endpoint/{endpoint}/live-view/{session-id}/heartbeat
Parameter Optional Type Datatype
endpoint no path String
session-id no path Integer

Renews the session’s idle deadline (expires-at). Clients should call this roughly every 60 seconds while the live-view screen is open.

Response: the session, in the same shape as Start a live-view session.

Error responses:

Status Body Description
404 Not found No such session, or the session belongs to a different device than endpoint
409 {"error": "...", "status": "..."} The session is no longer active (already reverted or superseded) — status gives its actual current state
Stop a live-view session DRAFT
Method Location
POST lwm2m/device/endpoint/{endpoint}/live-view/{session-id}/stop
Parameter Optional Type Datatype
endpoint no path String
session-id no path Integer

Ends the session and immediately reverts the config back to prior-config-version (doesn’t wait for the background sweep). If a concurrent config write happened to this device while the session was active, the session is marked superseded instead — the revert is skipped so the other write is never clobbered. Calling stop on an already-resolved session is idempotent and just returns its current state.

Response: the session, in the same shape as Start a live-view session.

Error responses:

Status Body Description
404 Not found No such session, or the session belongs to a different device than endpoint
Get live-view session status DRAFT
Method Location
GET lwm2m/device/endpoint/{endpoint}/live-view
Parameter Optional Type Datatype
endpoint no path String

Returns the device’s most recent live-view session, active or resolved — so a client can detect a superseded outcome even after the session has ended (e.g. on reopening the live-view screen).

Response: the session, in the same shape as Start a live-view session.

Error responses:

Status Body Description
404 Not found The device has never had a live-view session

Get device config schema DRAFT

Method Location
GET device/{device-id}/config-schema
Parameter Optional Type Datatype
device-id no path String

Get the configuration schema for a device based on its product type. The schema describes which configuration options the device supports, including sensor enable/disable toggles, measurement interval ranges, and available commands.

The product type is derived from the first 2 characters of the device’s product code (e.g., a device with code L103EWC0301 maps to product type L1).

Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)

Response: The config-schema JSON object, or null if the product type exists but has no schema configured.

A 200 response with null body means the device and its product type were found, but no configuration schema has been set for that product type yet. A 404 means the device or its product type was not found.

The schema is per product type, not tailored to the specific device’s firmware or hardware generation. Constraint fields like min_gen and min_ver are provided so that clients can determine which options are available on a particular device by comparing against the device’s product code and firmware version.

Keys use snake_case naming (e.g. extra_temp, pressure_diff_low_power). The keys in enable_bits and commands correspond to the schema key columns in the enable bit positions and command strings tables in the Device Configuration Format section above.

The schema is exhaustive — every known enable bit and command is always present in the response with at least a supported field. There are no implicit defaults; if an option is not relevant to the product type, it is explicitly listed as "supported": false.

Each enable-bit entry also carries its bit position, UI category, and behavior flags (inverted_logic, requires_reboot, requires_confirmation) in-band, so clients can build configuration UIs directly from the schema response without consulting the enable bit positions table. That table remains the wire-format reference for the raw e bitmask.

Example response (200) — product type L1 (Loop One):

{
  "enable_bits": {
    "microphone": {"position": 0, "category": "measurement", "supported": true},
    "particles": {"position": 1, "category": "measurement", "supported": true},
    "gps": {"position": 2, "category": "position", "supported": true, "inverted_logic": true, "requires_reboot": true},
    "temperature": {"position": 3, "category": "measurement", "supported": true, "min_gen": "03"},
    "humidity": {"position": 4, "category": "measurement", "supported": true, "min_gen": "03"},
    "co2": {"position": 5, "category": "measurement", "supported": true, "min_gen": "03"},
    "tvoc": {"position": 6, "category": "measurement", "supported": true, "min_gen": "03"},
    "light": {"position": 7, "category": "measurement", "supported": true, "min_gen": "03"},
    "pressure": {"position": 8, "category": "measurement", "supported": true, "min_gen": "03"},
    "motion": {"position": 9, "category": "measurement", "supported": true, "min_gen": "03"},
    "battery": {"position": 10, "category": "measurement", "supported": true, "min_gen": "03"},
    "extra_temp": {"position": 11, "category": "measurement", "supported": false},
    "debug_stats": {"position": 12, "category": "debug", "supported": true, "deprecated": true, "min_gen": "03"},
    "nw_stats": {"position": 13, "category": "debug", "supported": true, "deprecated": true, "min_gen": "03"},
    "pressure_diff_low_power": {"position": 14, "category": "measurement", "supported": false},
    "pressure_diff_accurate": {"position": 15, "category": "measurement", "supported": false},
    "pressure_diff_free_flow": {"position": 16, "category": "measurement", "supported": false},
    "datalogger": {"position": 17, "category": "connectivity", "supported": true, "min_gen": "03"},
    "network_backoff": {"position": 18, "category": "connectivity", "supported": true, "min_gen": "03"},
    "co2_abc": {"position": 19, "category": "measurement", "supported": true, "min_gen": "03"},
    "tvoc_index_off": {"position": 20, "category": "measurement", "supported": true, "inverted_logic": true, "min_gen": "03"},
    "tvoc_debug_not_active": {"position": 27, "category": "debug", "supported": true, "inverted_logic": true, "deprecated": true},
    "zero_adaptive_sending": {"position": 28, "category": "power", "supported": false},
    "power_save": {"position": 29, "category": "power", "supported": true},
    "default_cat_m": {"position": 30, "category": "connectivity", "supported": true, "requires_reboot": true, "requires_confirmation": true}
  },
  "interval": {"min": 10, "max": 86400},
  "particle_interval": {"supported": true, "min": 10, "max": 86400},
  "mic_duty_cycle": {"supported": true, "min": 0, "max": 100},
  "commands": {
    "reboot": {"supported": true},
    "locate_sound": {"supported": true, "min_gen": "03"},
    "calibrate_co2": {"supported": true, "min_gen": "03"},
    "reset_config": {"supported": true, "deprecated": true},
    "post_debug": {"supported": true, "deprecated": true},
    "reset_tvoc": {"supported": true, "deprecated": true}
  },
  "power_profile": {
    "min_gen": "03",
    "min_fw": null,
    "max_fw": null,
    "measurement_basis": {
      "source": "Spartalainen_virrankulutus.xlsx; modem re-measured 2026-05-18",
      "measured_at": "2024-03-04",
      "fw_version": "2.1.0"
    }
  },
  "power_profile_reason": null,
  "battery": {
    "code": "03",
    "description": "Li-Ion 2x4900 mAh",
    "capacity_mah": 9800,
    "nominal_voltage_v": 3.7
  },
  "battery_reason": null
}

Each entry in enable_bits and commands contains at minimum a supported boolean. Additional fields may be present:

Field Type Description
position integer Bit position (0–31) in the e bitmask. Present on enable_bits only; commands have no position.
category string UI grouping. One of measurement, position, connectivity, power, debug. Present on enable_bits only.
supported boolean Whether the device hardware/software supports this option.
inverted_logic boolean If true, setting the bit disables the feature. Default false.
requires_reboot boolean If true, the device must reboot for the change to take effect. Default false.
requires_confirmation boolean If true, UIs should ask the user to confirm before disabling/changing this option. Default false.
globally_deprecated boolean If true, the option is deprecated across all product types. Default false.
deprecated boolean If true, the option is deprecated for this specific product type and should not be used in new integrations.
min_ver string Minimum firmware version required (semver, e.g. "2.1.0"). Compare against the device’s firmware version.
min_gen string Minimum hardware generation required (e.g. "03" for Gen 2). Compare against positions 2–3 of the device’s product code.

position, category, inverted_logic, requires_reboot, requires_confirmation, and globally_deprecated are intrinsic to the bit/command and identical across all product types. The remaining fields are per-product overrides.

Battery & power profile fields — the response also carries four additive top-level keys that the battery-life estimator consumes. Clients that don’t need an estimate can ignore them.

Field Type Description
power_profile object | null The selected power profile entry for the device’s hardware generation and firmware version. null when no profile applies (see power_profile_reason). Fields: min_gen, min_fw, max_fw (the selection constraints), and measurement_basis (free-form provenance: source, measured_at, fw_version, notes).
power_profile_reason string | null null when a profile matched. Otherwise one of no_profile_for_device (no profile entry covers this device’s hardware generation; typical for Gen-1 devices in v1), no_firmware_version (the device has not reported a firmware version yet), fw_out_of_range (a profile exists but the device’s firmware version falls outside its min_fw/max_fw window — the estimator still returns a result, with a warning).
battery object | null The device’s battery hardware, resolved from positions 8–9 of the product code. Fields: code (2-char), description, capacity_mah, nominal_voltage_v, and external_power: true when the device is mains-powered (otherwise omitted). null when the battery code can’t be resolved (see battery_reason). The full catalog is available via Get battery codes.
battery_reason string | null null when the battery resolves to a battery-backed entry. mains_powered for mains devices, unknown_battery_code when the code is not present in the catalog, malformed_device_type when the device code is too short or doesn’t have alphanumerics in positions 8–9.

Error responses:

Status Body Description
403 Forbidden User does not have access to this device
404 Not found Device or product type not found

Get device default config DRAFT

Method Location
GET device/{device-id}/default-config
Parameter Optional Type Datatype
device-id no path String

Get the default device configuration for a device based on its product type. The defaults can be used to pre-populate a configuration form, or as a known starting point when resetting a device to factory defaults.

The product type is derived from the first 2 characters of the device’s product code (e.g., a device with code L103EWC0301 maps to product type L1). The returned object follows the Device configuration format — see that section for the meaning of each field and the bit positions inside e.

Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)

Example response (200) — product type L1 (Loop One):

{
  "e": 2147483647,
  "i": 600,
  "si": 3600,
  "md": 50
}

Error responses:

Status Body Description
403 Forbidden User does not have access to this device
404 Not found Device or product type not found, or no default config has been set for the product type

Estimate device battery life DRAFT

Method Location
POST device/{device-id}/usage-estimate
Parameter Optional Type Datatype
device-id no path String

Estimate the average current draw and resulting battery life for a proposed device configuration. The estimator combines:

The endpoint is read-only and idempotent — it is safe to debounce and re-issue on every keystroke in a config form.

Required role: org-admin, or building-admin, operator or device-configurator at the device’s location (see Get user grants)

Request body

{
  "config": {
    "e": 2147483647,
    "i": 600,
    "si": 3600,
    "md": 50
  }
}

The config object follows the Device configuration format. Validation rules:

Field Required when
e always
i always; must fall inside interval.min/interval.max from the config-schema
si only when the config-schema declares particle_interval.supported: true; must fall inside its declared range
md only when the config-schema declares mic_duty_cycle.supported: true; must fall inside its declared range

Unknown keys inside config (and unknown top-level keys in the request body) are accepted for forward compatibility. e bits set for features the product type does not support are accepted without error — the device firmware ignores bits for absent hardware.

Response shape

The response always contains battery, battery_reason, power_profile, power_profile_reason, and estimators (which is either an object with a battery estimate, or null when no estimate could be produced). Mains-powered devices additionally carry mains_powered: true. Unresolved battery codes additionally carry unknown_battery_code set to the offending 2-character code.

Field Type Description
battery object | null Same shape as the battery field on the config-schema response. null when the battery code is unknown or the device type is malformed.
battery_reason string | null null on success, otherwise mains_powered / unknown_battery_code / malformed_device_type.
unknown_battery_code string Present only when battery_reason = unknown_battery_code. The 2-character code that could not be resolved.
mains_powered boolean Present and true only when the device is mains-powered.
power_profile object | null Same shape as the power_profile field on the config-schema response.
power_profile_reason string | null null on success, otherwise no_profile_for_device / no_firmware_version / fw_out_of_range.
estimators.battery object | null The battery-life estimate when one can be produced. null when battery_reason or power_profile_reason is non-null (see “No-estimate cases” below).

The estimators wrapper exists so that future non-battery estimates (storage budget, telemetry volume, …) can be added alongside without breaking clients.

estimators.battery fields

Field Type Description
total_avg_ma number Sum of per-subsystem avg_ma, rounded to 3 decimals. Subsystems whose contribution cannot be characterised contribute 0.
hours integer | null Battery life at full charge, computed as battery.capacity_mah / total_avg_ma. null when the total is zero. Clients format to days / months / years as appropriate.
confidence string Share-weighted aggregate confidence: high, medium, low, or incomplete.
variance_pct number | null The aggregate variance, in percentage points, that produced the confidence bucket above. null when confidence is incomplete.
subsystems array Per-subsystem breakdown, sorted by descending avg_ma. See below.
warnings array of string Deduplicated, human-readable notes about the estimate — firmware reinterpretations of the proposed config, low-confidence subsystems, firmware-version mismatches, and uncharacterised subsystems.

Per-subsystem entry (estimators.battery.subsystems[i])

Field Type Description
key string Subsystem identifier (e.g. modem, microphone, base). The full set is per-product-type; consult the power profile for the product.
category string One of system, connectivity, measurement, position, power. Useful for grouping in a UI.
avg_ma number Average current contribution of this subsystem under the proposed config, rounded to 3 decimals.
share_pct number Share of total_avg_ma, rounded to 1 decimal.
confidence string Per-subsystem confidence: high, medium, low, or incomplete.
warning string | null An inline note when the subsystem itself carries one (e.g. “GPS power impact is not characterised”). The top-level warnings array is the authoritative list for the consumer; this field is for tooltips on the subsystem row.

Example response (200) — happy path, L1 device with default config

{
  "battery": {
    "code": "03",
    "description": "Li-Ion 2x4900 mAh",
    "capacity_mah": 9800,
    "nominal_voltage_v": 3.7
  },
  "battery_reason": null,
  "power_profile": {
    "min_gen": "03",
    "min_fw": null,
    "max_fw": null,
    "measurement_basis": {
      "source": "Spartalainen_virrankulutus.xlsx; modem re-measured 2026-05-18",
      "measured_at": "2024-03-04",
      "fw_version": "2.1.0"
    }
  },
  "power_profile_reason": null,
  "estimators": {
    "battery": {
      "total_avg_ma": 1.420,
      "hours": 6901,
      "confidence": "medium",
      "variance_pct": 17.4,
      "subsystems": [
        { "key": "microphone", "category": "measurement",
          "avg_ma": 0.575, "share_pct": 40.5,
          "confidence": "high", "warning": null },
        { "key": "particles",  "category": "measurement",
          "avg_ma": 0.367, "share_pct": 25.8,
          "confidence": "high", "warning": null },
        { "key": "modem",      "category": "connectivity",
          "avg_ma": 0.211, "share_pct": 14.9,
          "confidence": "medium", "warning": null },
        { "key": "base",       "category": "system",
          "avg_ma": 0.140, "share_pct":  9.9,
          "confidence": "high", "warning": null },
        { "key": "gps",        "category": "position",
          "avg_ma": 0.000, "share_pct":  0.0,
          "confidence": "incomplete",
          "warning": "gps power impact is not characterised" }
      ],
      "warnings": [
        "estimate excludes: gps",
        "modem contribution uses medium-confidence measurement"
      ]
    }
  }
}

No-estimate cases. When the estimator cannot produce a battery-life number, estimators is null and the reason fields carry the explanation. Consumers can branch on estimators === null as the single check.

Mains-powered (battery_reason: "mains_powered"):

{
  "battery": {
    "code": "30",
    "description": "No battery (external power)",
    "capacity_mah": null,
    "nominal_voltage_v": null,
    "external_power": true
  },
  "mains_powered": true,
  "battery_reason": "mains_powered",
  "power_profile": { "...": "..." },
  "power_profile_reason": null,
  "estimators": null
}

No power profile for this device generation (power_profile_reason: "no_profile_for_device" — typical for Gen-1 hardware):

{
  "battery": { "...": "..." },
  "battery_reason": null,
  "power_profile": null,
  "power_profile_reason": "no_profile_for_device",
  "estimators": null
}

Unknown battery code (battery_reason: "unknown_battery_code"):

{
  "battery": null,
  "battery_reason": "unknown_battery_code",
  "unknown_battery_code": "99",
  "power_profile": { "...": "..." },
  "power_profile_reason": null,
  "estimators": null
}

Warnings vocabulary. The strings in warnings are not enumerated — they are human-readable messages aimed at the person configuring the device. Typical patterns:

Clients should display these verbatim; do not parse them programmatically.

Error responses:

Status Body Description
400 {"error": "...", "field": "<i\|si\|md\|e>"} Config validation failed (missing required field or value out of declared range).
403 Forbidden User does not have access to this device.
404 {"error": "Device or product type not found"} The device or its product type is not on file.

Product Catalog APIs DRAFT

Get battery codes DRAFT

Method Location
GET product-catalog/battery-codes

Get the runtime catalog of battery hardware codes referenced by product codes. Positions 8–9 of each 11-character product code encode a 2-character battery code; this endpoint resolves those codes to their human-readable description, capacity, and nominal voltage. The catalog is a small map (a few dozen entries at most) and is safe to fetch once and cache for the duration of a session.

Required role: any user

Example response (200)

{
  "battery_codes": {
    "02": {
      "description": "Li-Ion 4900 mAh",
      "capacity_mah": 4900,
      "nominal_voltage_v": 3.7
    },
    "03": {
      "description": "Li-Ion 2x4900 mAh",
      "capacity_mah": 9800,
      "nominal_voltage_v": 3.7
    },
    "30": {
      "description": "No battery (external power)",
      "capacity_mah": null,
      "nominal_voltage_v": null,
      "external_power": true
    }
  }
}
Field Type Description
description string | null Human-readable name of the battery.
capacity_mah number | null Nominal capacity in milliamp-hours. null for mains-powered entries.
nominal_voltage_v number | null Nominal voltage at full charge, in volts. null for mains-powered entries.
external_power boolean Optional. When true, the entry represents a mains-powered device with no internal battery; capacity_mah and nominal_voltage_v are null in this case. Absent for battery-backed entries.

The same battery shape (plus a code field naming the catalog key) appears in the config-schema and usage-estimate responses for a single device.

Theme Configuration APIs DRAFT

Get default templates DRAFT

Method Location
GET theme-config/default-templates

Get default email and SMS templates from system configuration.

Required role: org-admin

Example response

{
  "email": {
    "alarm-notification": {
      "en": {
        "subject": "Alarm: {{location_name}}",
        "body": "An alarm was triggered at {{location_name}}..."
      },
      "fi": {
        "subject": "Hälytys: {{location_name}}",
        "body": "Hälytys laukesi kohteessa {{location_name}}..."
      }
    }
  },
  "sms": {
    "alarm-notification": {...}
  }
}

Preview template DRAFT

Method Location
POST theme-config/preview
Body Optional Datatype
theme no String
template-type no String
language no String
theme-config no JSON object
example-variables yes JSON object

Preview a rendered template with example variables.

Required role: org-admin

Example request

{
  "theme": "loopshore",
  "template-type": "alarm-notification",
  "language": "en",
  "theme-config": {
    "email-templates": {
      "alarm-notification": {
        "en": {
          "subject": "Alert: {{location_name}}",
          "body": "Temperature exceeded at {{location_name}}"
        }
      }
    }
  },
  "example-variables": {
    "location_name": "Office Building A"
  }
}

Success response

{
  "subject": "Alert: Office Building A",
  "body": "Temperature exceeded at Office Building A"
}

Send test message DRAFT

Method Location
POST theme-config/send-test
Body Optional Datatype
theme no String
message-type no String
template-type no String
language no String
recipient no String
theme-config no JSON object
example-variables yes JSON object

Send a test email or SMS message.

Required role: org-admin

Example request

{
  "theme": "loopshore",
  "message-type": "email",
  "template-type": "alarm-notification",
  "language": "en",
  "recipient": "test@example.com",
  "theme-config": {...}
}

Success response

{
  "success": true,
  "message": "Test email sent to test@example.com"
}

List themes DRAFT

Method Location
GET theme-config/themes

List available theme configurations. You see only your organization’s theme, and none when it is loopshore, which only Loopshore configures.

Required role: org-admin

Example response

[
  {
    "theme": "loopshore",
    "enabled": true,
    "smtp-password-is-set": true,
    "email-from-addr": "Loopshore <noreply@loopshore.com>",
    "smtp-host": "smtp.example.com",
    "smtp-port": 587,
    "service-domain": "app.loopshore.com",
    "sms-sender-name": "Loopshore"
  }
]

Get theme DRAFT

Method Location
GET theme-config/themes/{theme}
Parameter Optional Type Datatype
theme no path String (max 12 chars)

Get configuration for a specific theme.

Required role: org-admin

Example response

{
  "theme": "loopshore",
  "enabled": true,
  "smtp-password-is-set": true,
  "email-from-addr": "Loopshore <noreply@loopshore.com>",
  "smtp-host": "smtp.example.com",
  "smtp-port": 587,
  "smtp-user": "apikey",
  "service-domain": "app.loopshore.com",
  "sms-sender-name": "Loopshore",
  "email-templates": {...},
  "sms-templates": {...}
}

Update theme DRAFT

Method Location
PUT theme-config/themes/{theme}
Parameter Optional Type Datatype
theme no path String
Body Optional Datatype
enabled yes Boolean
email-from-addr yes String
email-bcc-on-activate yes String
smtp-host yes String
smtp-port yes Integer
smtp-user yes String
smtp-password yes String
service-domain yes String
sms-sender-name yes String
email-templates yes JSON object
sms-templates yes JSON object

Update theme configuration. Only include fields you want to change.

Required role: org-admin

Validation constraints:

Example request

{
  "smtp-host": "smtp.sendgrid.net",
  "smtp-port": 587,
  "smtp-user": "apikey",
  "smtp-password": "SG.xxxxx"
}

Device Problem Monitoring APIs DRAFT

Get active problems DRAFT

Method Location
GET device-problems/active
Parameter Optional Type Datatype
device-id yes query String
type yes query String
severity yes query String

Get the active problems of the devices your organization holds now. Only confirmed problems with critical or error severity are listed.

Required role: org-admin

Example response

[
  {
    "id": 1,
    "device-id": "device-001",
    "type": "communication",
    "severity": "critical",
    "manifestation": "confirmed",
    "status": "active",
    "reported-at": "2024-06-01T10:30:00Z",
    "code": "CONN_TIMEOUT",
    "description": "Device not responding for 24 hours"
  }
]

Filter values:

Parameter Valid values
type unknown, hw, sw, environmental, communication
severity critical, error, warning, notice, info

Get problem history DRAFT

Method Location
GET device-problems/history
Parameter Optional Type Datatype
start no query String (RFC3339)
end yes query String (RFC3339)
device-id yes query String
type yes query String
severity yes query String

Get historical device problems within a time range. Each problem is shown to the organization that held the device when the problem was reported, so problems from before your organization received a device are not included.

Required role: org-admin

Example response

[
  {
    "id": 1,
    "device-id": "device-001",
    "type": "communication",
    "severity": "critical",
    "manifestation": "confirmed",
    "status": "resolved",
    "reported-at": "2024-03-15T10:30:00Z",
    "resolved-at": "2024-03-16T08:00:00Z",
    "code": "CONN_TIMEOUT",
    "description": "Device not responding"
  }
]

Data Types Reference

Timestamp Format

All timestamps use ISO 8601 format with timezone:

2024-06-01T10:30:00Z

User Roles

A user’s roles are their grants: each role is granted at a location, which also covers every location below it, or on the organization. Read and change them with Get user grants and Replace user grants.

Tiers, from the narrowest:

Role What it gives Granted at
guest-viewer Viewing a location in another organization. Set by Loopshore. A location
viewer Viewing and naming locations, notes, display settings, status alarms, personal API keys, webhooks and share links. A location at building level or above
operator Viewer, plus installing, removing and configuring devices. A location at building level or above
building-admin Operator, plus editing locations below, managing users, quantity alarms and alarm thresholds, and surveys. A location at building level or above
org-admin Everything in your organization. The organization (group)

Add-ons, which give nothing without a tier at or above the same location:

Role What it gives Granted at
alarm-subscriber Quantity alarm subscriptions. On a guest-viewer grant, quantity and device-status alarm subscriptions; set there by Loopshore. A location at building level or above
threshold-editor Editing alarm thresholds. A location at building level or above
device-configurator Configuring devices. A location at building level or above

The role field on a user is user for everyone (Removed user fields). Use the grants, or widest-role in List users, for what a user may do.

Problem Types

Type Description
unknown Unknown problem type
hw Hardware issue
sw Software issue
environmental Environmental condition issue
communication Communication/connectivity issue

Problem Severity Levels

Severity Description
critical Requires immediate attention
error Error condition
warning Warning condition
notice Notable condition
info Informational

Problem Manifestation

Manifestation Description
occasional Problem occurs intermittently
confirmed Problem is confirmed/persistent

Revision history

Version Date Author Description
0.1 4.2.2021 J. Ratilainen Initial draft
0.2 11.2.2021 J. Ratilainen Add api-keys
0.2.1 20.4.2021 J. Ratilainen Clarify units
0.2.2 18.5.2021 J. Ratilainen Add pressure difference
0.3.0 8.9.2021 J. Ratilainen Add acceleration
0.4.0 20.3.2023 J. Ratilainen Add GPS coordinates
0.5.0 2.4.2023 J. Ratilainen Add shock
0.6.0 19.2.2024 J. Ratilainen Add dewpoint, absolute_humidity and tvoc_density
0.7.0 26.9.2024 J. Ratilainen Add support for extra quantities with _ syntax
0.8.0 3.3.2025 J. Ratilainen Add purpose & context to API keys
0.9.0 24.4.2025 J. Ratilainen Add weight, pressure_diff_min and pressure_diff_max
0.10.0 5.9.2025 J. Ratilainen Added CO. Added description to webhooks
0.11.0 1.12.2025 J. Ratilainen Added radon, batt, rsrp, snr, uptime, power_control, full_tx_power. Converted document to md file
0.12.0 23.1.2026 J. Ratilainen Added Admin APIs section with user, node, device, theme, and problem management endpoints
0.13.0 13.3.2026 J. Ratilainen Add voc_index
0.14.0 6.4.2026 J. Ratilainen Added User APIs [DRAFT] section: self-service, location tree, observation data, quantities, analysis, alarms, weather, location customization, surveys, i18n, short URLs. Added survey management and device PATCH to Admin APIs
0.14.1 11.4.2026 J. Ratilainen Clarify alarms: device_problem not POST-creatable, add breach condition, event response fields, POST response example, upper-threshold alarm example
0.14.2 12.4.2026 J. Ratilainen Rename privilege can-edit-alarms → alarm-editor, document available privileges and enforcement scope
0.14.3 12.4.2026 J. Ratilainen Clarify battery alarms use location_threshold (not device_problem), add battery-low example, add caution about device-specific timing for batt and no-data alarms
0.15.0 13.4.2026 J. Ratilainen Rewrite Device Installation Model: time-range records replace event model, add removal-time support, document update and device-specific list endpoints
0.15.1 14.4.2026 J. Ratilainen Add missing weight-unit and pm-unit to user settings
0.16.0 16.4.2026 J. Ratilainen Add location archive (soft-delete): archive/unarchive endpoints, archived_at field on node list, move validation for archive boundaries
0.17.0 16.4.2026 J. Ratilainen Add device config-schema endpoint; document device configuration format, enable bit positions, and command strings
0.18.0 18.4.2026 J. Ratilainen config-schema entries now include position, category, and behavior flags (inverted_logic, requires_reboot, requires_confirmation, globally_deprecated)
0.19.0 20.4.2026 J. Ratilainen quantities-v2 accepts optional start/end time window; when both given, each location entry includes observed-quantities listing quantities from installations overlapping the window
0.20.0 20.4.2026 J. Ratilainen last-quantities (when include-device-info=true) and device list now include product-name resolved server-side from the device-type prefix
0.21.0 24.4.2026 J. Ratilainen Added POST password/send-reset-email (admin-triggered password reset email, 14-day token); clarified password/reset-request token TTL (1 hour) and per-user rate limiting; added active, password-reset-pending, activation-email, password-reset-email fields to user list response
0.22.0 25.4.2026 J. Ratilainen User responses gain last-credential-login-at, last-refresh-at, and last-device-info (admin only). lastlogin reinterpreted as “latest activity” — the greater of credential login and mobile-session refresh. Applies to GET /user, GET /user/{id}, and GET /whoami; “Get user” response also documents active and password-reset-pending for parity with the list endpoint.
0.23.0 27.4.2026 J. Ratilainen Install endpoint accepts optional end-current-installation? flag that atomically closes the device’s current open installation before creating the new one (move-device flow).
0.23.1 27.4.2026 J. Ratilainen GET location/{id}/devices response now includes product-name (was missing from the example despite the 0.20.0 promise).
0.24.0 29.4.2026 J. Ratilainen Add device default-config endpoint (GET device/{device-id}/default-config); admin/user clients no longer need to guess defaults when populating config forms.
0.25.0 30.4.2026 J. Ratilainen Add device downsampled observations endpoint (observation/read-v2/device/{id}/downsampled); LTTB-downsampled time series for a single device.
0.26.0 4.5.2026 J. Ratilainen Add Location Notes (CRUD) under Extended User APIs: time-pinned annotations per location with shared/private visibility, author-only edits/deletes for private notes, and author-only visibility flips into or out of private.
0.27.0 18.5.2026 J. Ratilainen Add battery-life estimator: new POST device/{id}/usage-estimate, new GET product-catalog/battery-codes (Product Catalog APIs section), and four additive top-level fields on the device/{id}/config-schema response (power_profile, power_profile_reason, battery, battery_reason).
0.28.0 2.6.2026 J. Ratilainen GET location/{id}/csv is now streamed (no size limit) and gains optional query params: quantities, compact, utc, local, tz, separator, decimal, bom, preview. Defaults unchanged.
0.29.0 9.6.2026 J. Ratilainen last-quantities (with include-device-info=true) now returns a location-level device object sourced from the current installation, present even when the device has no in-scope measurements. Corrected the note that wrongly claimed an empty values map means no device is installed.
0.30.0 16.6.2026 J. Ratilainen API keys can be created read-only: POST api_key accepts an optional read-only boolean, GET api_key returns read_only per key, and read-only keys are rejected (403) on any mutating HTTP method.
0.31.0 2.7.2026 J. Ratilainen Added notable device settings: admin responses expose a notable-flags array ({key, tone, params}) highlighting attention-worthy config (capillary tubing, dense interval, power save off). Present on the device configuration read and the last-quantities device object; opt-in on the device list via include-notable-flags=true.
0.32.0 10.8.2026 J. Ratilainen Added Device Live View: POST/GET lwm2m/device/endpoint/{endpoint}/live-view[/...] temporarily drops a device’s interval to its schema floor for near-real-time monitoring, auto-reverting after an idle timeout or hard deadline. PUT device configuration gains an optional expected-version optimistic-concurrency guard (409 on mismatch) and now validates config.i against the device’s config-schema interval range (400 if out of range; previously unvalidated).
0.33.0 26.8.2026 J. Ratilainen Documented location/{id}/observations/downsampled (LTTB points plus window min/max/mean, no range limit) and added a Choosing an endpoint guide to Reading Observation Data. Clarified that observations/downsampled-v2 is a points-only variant; it now serves ranges of any length. Corrected the aggregates response: min/max are whole buckets selected by extreme value (not by average), avg is now weighted by each bucket’s observation count, the end bound is exclusive, and samples selects the bucket period as well as the LTTB target. The location downsampled endpoints now cap samples at 2000, matching the device one. All three downsampled endpoints may return truncated: true when a range is too large to read in full.
0.34.0 4.9.2026 J. Ratilainen Added the locate sound command: c accepts "b" (schema key locate_sound), which makes the device play a chime for about a minute so it can be found. Documented that the device re-runs a stored c on every configuration version bump, so read-modify-write clients must clear it themselves.
0.35.0 9.9.2026 J. Ratilainen Documented enable bit 21 (spl_pause_during_fan), previously listed as unused: the device pauses sound level measurement while the particle sensor fan runs, so readings no longer include the device’s own fan noise. Set by default. At intervals short enough to keep the fan running continuously the device reports no sound level at all. Updated the bitmask example to keep the bit set.
0.35.1 18.9.2026 J. Ratilainen Documented the group-access field on List devices: the periods during which each device is assigned to the admin’s group. The field was already returned but not documented.
0.36.0 30.9.2026 J. Ratilainen Added the group_suspended error: once an organization’s access to the service ends, login, token refresh and every authenticated request (including API keys) return 403 {"error": "group_suspended"}. Login returns it only after a correct password; refresh leaves the token usable for when access is restored. Added the group-access-ends-at field to Get current user.
0.37.0 5.10.2026 J. Ratilainen List users and Get user gain last-api-key-used-at (share links included), last-webhook-delivered-at, last-alarm-sent-at, created-at and created-at-estimated; their lastlogin now counts all of these sources. API key and webhook times update at most once a day. List subscriptions gains last-delivered-at. Get current user is unchanged. Removed mentions of internal roles.
0.38.0 6.10.2026 J. Ratilainen Added Suspend user and Unsuspend user. Login, Refresh token and every authenticated request can return 403 {"error": "user_suspended"}. List users and Get user gain suspended-at, suspended-by and suspended-by-name.
0.39.0 6.10.2026 J. Ratilainen Added Get user impact: what suspending or deleting a user affects, with the last use of each item.
0.40.0 7.10.2026 J. Ratilainen Missing, expired or invalid authentication now returns 401 on every endpoint that requires it. Most endpoints previously returned 403 in this case. A suspended user or organization now gets its user_suspended or group_suspended error on every endpoint, not a bare 403. Corrected Authorization: a resource you can’t access returns 403 or 404, not 401. Corrected the 401 example: the body is empty.
0.41.0 9.10.2026 J. Ratilainen Get current user gains features and permissions. Added Users with access. Impersonation acts fully as the target and ends with the impersonator’s own access. Webhooks with scope “all” cover an admin’s whole organization and skip archived locations. Corrected the admins’ location scope. Announced upcoming changes: 404 for resources outside what you can see on DRAFT endpoints, and the alarm-editor check on Set alarm configuration.
0.42.0 12.10.2026 J. Ratilainen Observation history and Latest observations describe which periods of a device’s data you receive. Latest observations return an empty list for a device currently installed at an archived location.
0.43.0 12.10.2026 J. Ratilainen List devices no longer shows the location, installation time or notable settings of a device your organization doesn’t hold now. Get problem history shows each problem to the organization that held the device when it was reported. Described which devices and installations List devices, List installations and Get active problems return.
0.44.0 13.10.2026 J. Ratilainen Get location tree contains every location you can view when none of your selected locations is available. Get analysis results, List survey-location assignments and Get survey results exclude archived locations. Get alarm events and Get alarm state return only alarms at locations you can view now.
0.45.0 13.10.2026 J. Ratilainen The AI helper belongs to the organization. Added Set user AI helper setting: admins switch it off for a user, or back to following the organization. List users and Get user document feature-overrides.
0.46.0 13.10.2026 J. Ratilainen A refused request on a DRAFT endpoint returns 404 for a location, group, device, installation or user outside what you can see, and 403 when you can see it but aren’t allowed the action. Previously some returned 403, and installing a device at a location outside your organization returned 400. Set alarm configuration requires the alarm-editor privilege for alarms on measured quantities.
0.46.1 13.10.2026 J. Ratilainen Observation history applies the UTC offset of end; previously an offset other than Z was ignored and the window ended at that clock time in UTC. Described the order of the observations and what happens at the 5000-observation limit.
0.46.2 13.10.2026 J. Ratilainen Observation history V2 (cursor-based): end is exclusive.
0.47.0 15.10.2026 J. Ratilainen A share link that no longer works returns 403 {"error": "share_unavailable"} on every request, whatever the reason: deleted or archived location, location outside the owner’s access, or a suspended owner or organization. A suspended user’s share links previously returned user_suspended or group_suspended. An unknown API key returns 401 {"error": "invalid_api_key"} and is no longer passed over for a session cookie or Bearer token sent in the same request.
0.47.1 16.10.2026 J. Ratilainen Set alarm configuration: an aqi-curve that doesn’t have two distinct points or doesn’t cross both score 2 and score 5 returns 400 and saves nothing; previously such a curve returned 500 or was saved. Leaving out aqi-curve is the same as null. Described that the custom curve belongs to the location, and how to build a curve from alarm and goal limits.
0.48.0 16.10.2026 J. Ratilainen User nodes and privileges are read from the user’s access: deleted locations are no longer returned, a location in another organization is view-only and stays when an admin saves the user without it, and a user’s alarm-editor is returned only when they have locations in your organization. An admin’s nodes sets the locations shown first in the measurements tree and those their share links can show.
0.49.0 19.10.2026 J. Ratilainen Observation history, the cursor-based histories, downsampled observations and Aggregated observations: only a few requests from one user account run at a time. A request over the limit returns 429 {"error": "too_many_requests"} with Retry-After, and a read that takes too long returns 504 {"error": "query_timeout"}. Added Concurrent history requests.
0.50.0 19.10.2026 J. Ratilainen Update installation: a new installation-time before your organization had the device returns 403.
0.51.0 19.10.2026 J. Ratilainen Added Get user grants and Replace user grants: roles, tiers and add-ons, who may add or remove which grant.
0.52.0 20.10.2026 J. Ratilainen Users with access gains role, each user’s widest tier at the location. Create user takes grants, stored with the user. Creating a user or replacing grants returns 400 when you manage users in some locations only and the user would hold no role in them.
0.53.0 20.10.2026 J. Ratilainen List devices gains location-name, the name of the device’s current location, and states who may list devices and which: Operators, Building admins and Device configurators get the devices installed at their locations.
0.54.0 20.10.2026 J. Ratilainen List users gains manageable: false for a listed user you can read but not change. List users and List nodes state who may call them and what they return; Operators now get the locations they install devices at from List nodes.
0.55.0 21.10.2026 J. Ratilainen Replace user grants and Create user give location grants at a building or above only: a new grant inside a building is 400. Earlier grants inside a building stay and can be removed.
0.56.0 21.10.2026 J. Ratilainen Update node merges data into the stored data, and a key sent as null is removed; before, data replaced the stored data. data.address is a string of at most 500 characters. It states who may change what: the location a role is given at changes its own name and address.
0.57.0 22.10.2026 J. Ratilainen notable-flags goes to everyone who sees the measurements in last-quantities, except a share link, and to everyone who may read a device’s configuration in the device configuration read. Before, only admins got it.
0.58.0 22.10.2026 J. Ratilainen List users gains widest-role: the widest role the user holds. role stays for older clients.
0.58.1 23.10.2026 J. Ratilainen Device and LWM2M endpoints state their required roles in the new roles; they had said admin or user. Configuring a device, its config schema, default config, battery-life estimate and Live View need org-admin, or building-admin, operator or device-configurator at the device’s location. Removing a device and changing or deleting an installation need org-admin, or building-admin or operator at the location. Installing a device needs org-admin. The configuration read, configuration history and LWM2M device list gain a required role; the history is org-admin only. Theme configuration and device problem endpoints gain a required role: org-admin. The Admin APIs introduction no longer says they need the admin role, and User Roles lists the tiers and add-ons instead of user and admin. Every Admin API, and every User API that changes a location’s settings, notes, images or alarms, states its required role. The descriptions of role, nodes and profile.privileges say how these legacy fields follow the grants and change them when written, and Update user says it doesn’t take grants. Access notes on observations, webhooks, alarms, archived locations, surveys, the LWM2M device list, themes, device problems and impersonation are written in terms of roles. The Theme rules no longer say you can assign only themes you have access to. Links to endpoint sections point to the right headings again.
0.59.0 23.10.2026 J. Ratilainen Alarm thresholds are set separately from alarms. New Get location threshold, Set location threshold and Delete location threshold; setting and deleting need alarm threshold editing (org-admin, building-admin or threshold-editor). Set alarm configuration for a measured quantity needs only quantity alarm subscriptions (org-admin, building-admin or alarm-subscriber); aqi-curve null or left out keeps the location’s curve instead of removing it, and sending a different curve is deprecated. A curve change gives every alarm there a threshold event and returns it to idle. The docs no longer say a missing curve falls back to default thresholds for alarms: without a curve, threshold alarms don’t fire.
0.60.0 23.10.2026 J. Ratilainen Share links: a write link writes notes and changes display settings only; it no longer sets alarms or changes derived quantities. A link works wherever its owner sees the shared location, an org-admin’s through their whole organization; the owner’s selected locations no longer limit it, and its tree is the shared location with the locations below it. Get current user permissions gains share with the shared location and the link’s mode.
0.61.0 26.10.2026 J. Ratilainen Installing a device needs org-admin, or building-admin or operator at the location, and the device must be your organization’s at installation-time; it had needed org-admin. A move (end-current-installation?) also needs one of these roles where the device is installed now. 404 for a device or location outside what you can see, 403 otherwise. New List install candidates: your organization’s devices with no active installation. In User Roles, alarm-subscriber on a guest-viewer grant gives quantity and device-status alarm subscriptions there, set by Loopshore; Set alarm configuration accepts it for device-status alarms.
0.62.0 26.10.2026 J. Ratilainen Replace user grants: an org-admin holds no viewer grant in their organization, nor the add-ons only such a grant carries, and a request that gives them stores the user without them; removing org-admin leaves the other grants in the set. Nodes: an org-admin’s locations in their organization are only the tree preference and give no grant. Get location tree: for a share link, the tree is the shared location alone; the locations below it, which a share link can’t read, are no longer listed. Get analysis results: a share link no longer gets the locations below the shared location. Theme: the theme belongs to the organization; a user in one has its theme, theme in Create user and Update user is deprecated and may only repeat it or be null, and an unknown theme answers 400. List themes: an org-admin sees their organization’s current theme.
0.63.0 28.10.2026 J. Ratilainen New Deprecated user fields: nodes, profile.privileges and the role value admin are deprecated and will be removed; after that role is user for every user, and a user write sending the removed fields, or a different role, answers 400. Use permissions and the user grants instead.
0.64.0 28.10.2026 J. Ratilainen Impersonate user: an org-admin may impersonate another org-admin of the organization; who may be impersonated follows the same rule as managing users.
0.65.0 2.11.2026 J. Ratilainen Set alarm configuration: for a measured quantity, a request sending aqi-curve returns 400, also when it is the curve already set; set the curve with Set location threshold. null or no aqi-curve keeps the location’s curve, as before. batt, rsrp and snr are unchanged.
0.66.0 2.11.2026 J. Ratilainen Removed user fields: nodes, profile.privileges and the role value admin are removed. role is user for every user, and Create user and Update user answer 400 to nodes, profile.privileges or a different role; repeating the current role changes nothing. Adding or removing org-admin in Replace user grants no longer changes role. New tree-locations in Update settings: the measurements tree preference, which was an org-admin’s nodes; see Profile. Update settings stores every setting sent, and Update user merges profile.settings key by key.
0.67.0 9.11.2026 J. Ratilainen New known quantities pn0p5, pn1p0, pn2p5, pn4p0, pn0p3 (#/m3) and tps (um). New enable bit 22 particle_counts_off, inverted. Calibration data format: q is the measurement resource ID (the examples had wrong IDs), the calibratable quantities, at most 12 entries of 5 points. Write device configuration rejects a config longer than 990 bytes with 400.

LOOPSHORE | Hämeenkatu 13 A 5, 33100 Tampere | info@loopshore.com | loopshore.com