Loopshore API

API Reference

Version 0.35.1

Introduction

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

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

Terminology

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

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

API location

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

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

Authentication

There are four ways to authenticate to the API.

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 is only allowed to access the resources that are configured to the user by system admins. If you are trying to access a resource that you have not access to, 401 error code will be returned. Contact Loopshore if you think that you should have access to a resource.

Restrictions

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

If you want to exceed this restriction, contact Loopshore.

Example 1

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

API Usage

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
404 Not found - resource doesn’t exist or isn’t accessible

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

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

Common error examples:

Authentication missing or expired:

{"error": "Unauthorized", "message": "Authentication required"}

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

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

Resource not found or outside your scope:

"No such user"

Duplicate username:

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

Invalid path format when creating location:

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

Error handling guidance:

Known quantities

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

Quantities and units are case sensitive.

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

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

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

User APIs STABLE

These APIs are available to all authenticated users for reading and writing observation data.

Login STABLE

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

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

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

Example request

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

Example response header

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

Example use of cookie in a request

Cookie: jabster_token=<token>

Mobile mode

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

Example request

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

Example response

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

Response fields (mobile mode):

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

Example use of access token in a request

Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...

Refresh token STABLE

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

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

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

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

Example request

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

Example response

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

Error responses:

Status Body Description
401 {"error": "invalid_refresh_token"} Token not found, expired, or already rotated
401 {"error": "token_reuse_detected"} Reuse of a rotated token — all session tokens revoked

Acquire apikey STABLE

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

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

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

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

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

id is used to identify a key in other APIs.

Example request

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

Example response

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

Notes:

  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.

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

Response example

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

Usage example

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

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

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

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

Latest observations STABLE

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

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

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

Response example

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

Subscribe observations STABLE

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

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

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

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

Webhooks can be targeted to single device-ids or all the devices that user has access to (scope). Only valid value for scope is “all”.

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

Simple example for single device

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

More complex example for all devices with custom headers

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

Response

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

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

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

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

Example response

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

List subscriptions STABLE

Method Location
GET webhook

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

Example response

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

Cancel observation subscription STABLE

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

This API can be used to remove a subscription.

Test subscriptions STABLE

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

Sends a test message to webhook url.

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

nonce – is the nonce for the webhook id.

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

Example message sent to url server

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

Example of message identical to real data

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

Send observations STABLE

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

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

Body example

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

Extended User APIs DRAFT

These APIs are available to all authenticated users. They extend the stable APIs above with additional features for managing user preferences, browsing locations, reading measurement data, configuring alarms, and more.

Self-Service DRAFT

Get current user DRAFT

Method Location
GET whoami

Returns the profile data of the currently authenticated user.

Example response

{
  "id": 1,
  "name": "user@example.com",
  "role": "user",
  "email": "user@example.com",
  "emailConfirmed": true,
  "language": "fi",
  "theme": "loopshore",
  "nodes": [5, 12, 47],
  "profile": {
    "privileges": ["alarm-editor"]
  },
  "phonenum": "+358501234567",
  "lastlogin": "2024-06-01T10:30:00Z",
  "last-credential-login-at": "2024-05-28T08:00:00Z",
  "last-refresh-at": "2024-06-01T10:30:00Z"
}

Response fields:

Field Type Description
id integer User ID
name string Username
role string User role (user or admin)
email string Email address
emailConfirmed boolean Whether email has been verified
language string UI language code (2-char: en, fi, sv)
theme string Theme identifier
nodes int[] Location IDs user can access
profile object User preferences and privileges
phonenum string Verified phone number in international format
phonenum-unverified string Phone number pending verification (present only during verification)
lastlogin datetime | null Latest activity timestamp — the greater of last-credential-login-at and last-refresh-at. null if neither has occurred.
last-credential-login-at datetime | null Last time the user completed a username/password login (web or mobile). null if never.
last-refresh-at datetime | null Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started.

Admins and superadmins additionally receive last-device-info (the device_info string from the most recent mobile session). It is stripped from this endpoint for regular users; see the admin user endpoints for the full shape.

Logout DRAFT

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

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

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

Example request (mobile)

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

Set language DRAFT

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

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

Example request

{
  "language": "fi"
}

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

Update settings DRAFT

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

Update display unit preferences. All fields are optional — only include fields you want to change. Settings are synced across web and mobile.

Example request

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

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

Set phone number DRAFT

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

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

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

Example request

{
  "phonenum": "+358501234567"
}

Example response

{
  "verification-needed": true
}

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

Verify phone number DRAFT

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

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

Example request

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

Request password reset DRAFT

Method Location
POST password/reset-request
Body Optional Datatype
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 admin-initiated reset emails with longer-lived tokens, see POST password/send-reset-email under Admin APIs.

Example request

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

Example response

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

Location Tree & Details DRAFT

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

Get location tree DRAFT

Method Location
GET location/tree/my-locations

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

Example response

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

Node fields:

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

Get location details DRAFT

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

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

Example response

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

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

List devices at location DRAFT

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

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

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

Example response

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

Reading Observation Data DRAFT

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

Three approaches to reading observation data:

  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.

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

Example response

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

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

Location observation history V2 (cursor-based) DRAFT

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

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

Downsampled observations DRAFT

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

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

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

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

Example response

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

Downsampled observations, points only DRAFT

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

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

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

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

Example response

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

Device downsampled observations DRAFT

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

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

Both users and admins receive data only for devices and time windows they have access to. If the caller has no access for the device in the requested window, an empty observations list is returned.

Example response

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

Aggregated observations DRAFT

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

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

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

  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).

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:

Breach condition:

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

Get alarm events DRAFT

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

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

Example response

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

Event types:

Event type Description
activated Alarm triggered after delay expired
pending Threshold breached, waiting for delay
deactivated Alarm cleared
email_notification Email notification sent
sms_notification SMS notification sent
threshold_update Alarm threshold was changed
threshold_deleted Custom threshold removed (reverted to defaults)
delay_update Delay setting was changed
hysteresis_update Hysteresis setting was changed
alarm_enabled Alarm was enabled
alarm_disabled Alarm was disabled
email_enabled Email notifications enabled
email_disabled Email notifications disabled
sms_enabled SMS notifications enabled
sms_disabled SMS notifications disabled
device_problem_activated Device problem alarm triggered
device_problem_resolved Device problem resolved
acknowledgement Legacy value — present in the database enum but not produced by the current backend. May appear in historical data.

Get alarm state DRAFT

Method Location
GET alarm/state

Returns the current state of all alarms configured by the current user.

Example response

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

Get alarm configuration DRAFT

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

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

Example response

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

Set alarm configuration DRAFT

Method Location
POST alarm/location/{id}/alarm-properties-v2
Parameter Optional Type Datatype
id no path Integer (location ID)
Body Optional Datatype
delay no Integer (minutes, >= 0)
hysteresis no Integer (minutes, >= 0)
medium no Integer[] (notification channels)
config-type yes String: location_threshold (default) or location_no_data
quantity yes String
aqi-curve yes Array (threshold curve, null to use defaults)
enabled yes Boolean (default true)

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

Example request

{
  "delay": 30,
  "hysteresis": 15,
  "medium": [1, 2],
  "config-type": "location_threshold",
  "quantity": "temperature",
  "aqi-curve": [[15.0, 0.0], [20.7, 5.0], [21.3, 5.0], [27.0, 0.0]],
  "enabled": true
}

Example response

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

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

Example: upper-threshold alarm

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

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

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

How the curve works:

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:

Weather Data DRAFT

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

Search weather stations DRAFT

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

Search for weather stations by name.

Example response

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

Get weather observations DRAFT

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

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

Set weather station for location DRAFT

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

Link a weather station to a location.

Example request

{
  "station-id": 1
}

Remove weather station from location DRAFT

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

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

Set weather display configuration DRAFT

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

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

Location Customization DRAFT

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

Location images DRAFT

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

Upload image

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

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

Example response

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

Download image

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

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

Delete image

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

Returns 204 on success.

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

Blueprint coordinates DRAFT

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

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

PUT request/response example:

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

Derived quantities DRAFT

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

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

POST/PUT request example:

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

Graph settings DRAFT

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

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

PUT request example:

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

Location Notes DRAFT

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

Visibility model

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

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

Audit fields

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

Body length

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

List notes DRAFT

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

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

Example response

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

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

Create note DRAFT

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

Request body

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

Example request

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

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

Update note DRAFT

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

Request body

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

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

Authorisation

Delete note DRAFT

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

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

Authorisation

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 require admin role and are used for managing users, devices, and locations. Admin users can only manage resources within their assigned scope.

Scope and Permissions:

Admin users are restricted to their assigned group. This means:

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

Admin API Authentication

Admin APIs use the same authentication methods as User APIs:

Key Concepts

Nodes (Location Access)

The nodes field is an array of location IDs that determine which parts of the location tree a user can access. Locations are organized hierarchically (building → floor → room), and each location has a numeric ID.

"nodes": [5, 12, 47]

How it works:

Use GET /api/node to list available locations and their IDs.

Note: Access should be granted to at least building level. Granting access to only a single room may cause UI issues — the user cannot navigate the location hierarchy and the frontpage will be empty.

Theme

The theme field links the user to a theme configuration that controls:

"theme": "loopshore"

Rules:

Use GET /api/theme-config/themes to see available themes.

Profile

The profile field stores user preferences as a JSON object:

"profile": {
  "privileges": ["alarm-editor"]
}
Field Type Description
privileges string[] Feature flags/permissions for the user

Available privileges:

Privilege Description
alarm-editor Allows the user to configure alarm settings in the UI

Note: Privileges are currently used as hints to the user interface (e.g. hiding or showing alarm configuration controls). They are not enforced by the backend API — a future version may add server-side enforcement.

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

Path (Location Hierarchy)

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

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

Rules:

Examples:

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

Tag (Node Type)

Valid node type values:

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

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

Data (Node Metadata)

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

quantity-names

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

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

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

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

quantities

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

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

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

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

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

graph-settings

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

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

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

blueprint-coordinates

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

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

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

derived-quantities

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

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

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

User Management APIs DRAFT

Send activation email DRAFT

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

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

Example request

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

Success response

{
  "message": "Activation email sent"
}

Send password reset email DRAFT

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

Admin-triggered: email a password reset link to an existing user. Intended for helping a user who has lost access or locked themselves out. The token is long-lived (14 days by default) so the user has time to react and so the admin can safely resend. Not rate-limited. Returns 404 for unknown users — enumeration defenses are unnecessary behind admin auth.

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

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 the admin needs to deliver the link out-of-band (SMS, chat) rather than via email.

Example response

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

List users DRAFT

Method Location
GET user

List all users in your scope.

Example response

[
  {
    "id": 1,
    "name": "admin@example.com",
    "role": "admin",
    "email": "admin@example.com",
    "language": "en",
    "theme": "loopshore",
    "nodes": [1, 2, 3],
    "profile": {"privileges": ["alarm-editor"]},
    "emailConfirmed": true,
    "lastlogin": "2024-06-01T10:30:00Z",
    "last-credential-login-at": "2024-05-28T08:00:00Z",
    "last-refresh-at": "2024-06-01T10:30:00Z",
    "last-device-info": "Pixel 9 Pro",
    "active": true,
    "password-reset-pending": false,
    "activation-email": {
      "sent-at": "2024-05-28T09:15:00Z",
      "sent-by-name": "superadmin",
      "status": "ok",
      "error-message": null
    },
    "password-reset-email": null
  }
]

Response fields:

Field Type Description
id integer User ID
name string Username
role string User role (user or admin)
email string Email address
language string UI language code
theme string Theme identifier
nodes int[] Location IDs user can access
profile object User preferences
emailConfirmed boolean Whether email has been verified
phonenum string Phone number
lastlogin datetime | null Latest activity timestamp — the greater of last-credential-login-at and last-refresh-at. null if neither has occurred.
last-credential-login-at datetime | null Last time the user completed a username/password login (web or mobile). null if never.
last-refresh-at datetime | null Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started.
last-device-info string | null device_info string from the most recent mobile session. null if no mobile session has ever been started. Returned to admin/superadmin viewers only.
active boolean true once the user has set a password
password-reset-pending boolean true while a reset token is outstanding; flips to false when the user consumes the reset link
activation-email object | null Most recent activation email sent to this user, or null if none has been recorded
password-reset-email object | null Most recent password-reset email sent to this user, or null if none has been recorded

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

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

Create user DRAFT

Method Location
POST user
Body Optional Datatype
name no String
email yes String
password yes String
role yes String
language yes String
theme yes String
nodes yes Integer[]
profile yes JSON object

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

Example request

{
  "name": "maintenance@example.com",
  "email": "maintenance@example.com",
  "role": "user",
  "language": "fi",
  "nodes": [25, 26, 27]
}

Success response

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

Typical workflow:

  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.

Example response

{
  "id": 15,
  "name": "user@example.com",
  "email": "user@example.com",
  "role": "user",
  "language": "en",
  "theme": "loopshore",
  "nodes": [5, 6],
  "profile": {},
  "emailConfirmed": false,
  "phonenum": "+358501234567",
  "lastlogin": "2024-05-15T14:20:00Z",
  "last-credential-login-at": "2024-05-15T14:20:00Z",
  "last-refresh-at": "2024-05-14T09:05:00Z",
  "last-device-info": "iPhone 15 Pro",
  "active": true,
  "password-reset-pending": false
}

Response fields:

Field Type Description
id integer User ID
name string Username
role string User role (user or admin)
email string Email address
language string UI language code
theme string Theme identifier
nodes int[] Location IDs user can access
profile object User preferences
emailConfirmed boolean Whether email has been verified
phonenum string Phone number
lastlogin datetime | null Latest activity timestamp — the greater of last-credential-login-at and last-refresh-at. null if neither has occurred.
last-credential-login-at datetime | null Last time the user completed a username/password login (web or mobile). null if never.
last-refresh-at datetime | null Last time the user’s mobile session was refreshed (initial mobile login or token rotation). null if no mobile session has ever been started.
last-device-info string | null device_info string from the most recent mobile session. null if no mobile session has ever been started. Returned to admin/superadmin viewers only.
active boolean true once the user has set a password
password-reset-pending boolean true while a reset token is outstanding; flips to false when the user consumes the reset link

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

Update user DRAFT

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

Update user details. Only include fields you want to change.

Example request - Promote user to admin:

{
  "role": "admin",
  "nodes": [1, 2, 3, 4, 5]
}

Note: When updating nodes, the entire list is replaced.

Delete user DRAFT

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

Delete a user. Returns 204 on success.

Get user operation stats DRAFT

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

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

Example response

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

Check username availability DRAFT

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

Check if a username is available for new user creation.

Example response

{
  "available": true
}

Impersonate user DRAFT

Method Location
POST imposter
Body Optional Datatype
name no String

Login as another user for support purposes.

Example request

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

Success response

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

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

Node/Location Management APIs DRAFT

List nodes DRAFT

Method Location
GET node

List all nodes visible to the current admin. Returns a flat list.

Example response

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

Response fields:

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

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

Create node DRAFT

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

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

Example request

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

Success response

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

Get node DRAFT

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

Get node details by ID.

Example response

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

Update node DRAFT

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

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

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

Example request

{
  "name": "Floor 1 - Main Office",
  "data": {"area_sqm": 550, "renovated": true}
}

Delete node DRAFT

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

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

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

Archive node DRAFT

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

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

Returns 409 Conflict if the node is already archived.

Example request

{
  "path": "helsinki.office"
}

Example response

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

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

Effects of archiving:

Unarchive node DRAFT

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

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

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

Example response

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

The response lists all nodes that were unarchived.

Quantity Whitelist APIs DRAFT

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

Get quantity whitelist DRAFT

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

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

Example response

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

Set quantity whitelist DRAFT

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

Set or update the quantity whitelist for a location.

Example request

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

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

Remove quantity whitelist DRAFT

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

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

Device Management APIs DRAFT

About device-id. A device’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 all devices visible to the current admin.

Required role: superadmin, admin

Query parameters:

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

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

Example response

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

Response fields:

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

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

The field is computed only for admin and superadmin callers and appears in three places: the device list above (with include-notable-flags=true), the location-level device object of last-quantities, and the device configuration read.

Each entry has:

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

Possible key values:

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

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

Install device DRAFT

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

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

Required role: superadmin, admin

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

Pass end-current-installation?: true to move a device from its current room in one call.

Example request (open-ended)

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

Example request (bounded)

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

Example request (move from current room)

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

Success response

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

Error response (overlap)

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

Remove device DRAFT

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

Remove a device from its current location by setting removal-time on its active (open-ended) installation. Returns 400 if the device is not currently installed anywhere.

Required role: superadmin, admin

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

Example request

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

Success response

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

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

List installations DRAFT

Method Location
GET installations

List all installation records across all devices, ordered by installation time (newest first).

Required role: superadmin, admin

Example response

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

Response fields:

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

List device installations DRAFT

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

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

Required role: superadmin, admin

Example response

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

Update installation DRAFT

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

Update the times on an existing installation record. Only include fields you want to change. Returns 400 if the update would cause an overlap with another installation (same device at two locations, or two devices at the same location).

Required role: superadmin, admin

Example request — set removal time

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

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

{
  "removal-time": null
}

Success response

Returns the updated installation record.

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

Delete installation DRAFT

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

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

Required role: superadmin, admin

Returns 200 with the deleted record on success.

Survey Management APIs DRAFT

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

List survey summaries DRAFT

Method Location
GET survey2/summary

List all surveys with summary information.

Example response

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

List survey-location assignments DRAFT

Method Location
GET survey2/locations

List which surveys are assigned to which locations.

Example response

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

Assign survey to location DRAFT

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

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

Example request

{
  "survey-id": 42
}

Remove survey from location DRAFT

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

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

Get survey results DRAFT

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

Get all survey responses within the given time range, across all locations the admin has access to. Results include both choice and free-text responses.

Example response

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

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

LWM2M Device Configuration APIs DRAFT

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

Device configuration format

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

Configuration fields:

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

Example configuration:

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

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

Field Type Description
q integer Quantity ID being calibrated (e.g. 5 = CO2, 32 = differential pressure).
p array Calibration points, each a [raw, calibrated] pair sorted by raw ascending.

Example cal value:

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

Setting and clearing calibration on the device:

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

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

Note for client implementors. Because 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 400 ppm)
20 tvoc_index_off TVOC index sensor (VOC Index 1–500) Inverted logic — bit 1 = TVOC index OFF
21 spl_pause_during_fan Pause sound level measurement while the particle sensor fan runs Defaults to 1 (enabled)
22–26 — Unused
27 tvoc_debug_not_active TVOC debug Inverted logic — bit 1 = OFF. Deprecated
28 zero_adaptive_sending Adaptive sending (Zero hardware only)
29 power_save Power saving when not on USB power
30 default_cat_m Prefer Cat-M over NB-IoT Requires reboot

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

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

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

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

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

Command strings

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

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

List LWM2M devices DRAFT

Method Location
GET lwm2m/device-v2

List all LWM2M devices with their latest configuration and firmware information.

Example response

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

Response fields:

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

Visibility note. For admins without current access to a device, the config-*, config, comment, target-hw, and all firmware-* fields are omitted from that device’s row entirely (not set to null). Superadmins always see every field. Clients should treat all of these fields as both nullable and optional.

Get device configuration DRAFT

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

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

Response fields:

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

Example response

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

Get configuration history DRAFT

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

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

Example response

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

Write device configuration DRAFT

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

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

Required role: superadmin, admin, factory

Warning — read-modify-write. The submitted 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).

Example request body

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

Example response

{
  "version": 6
}

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

Error responses:

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

Device Live View DRAFT

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

Required role: superadmin, admin, factory

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

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

A session ends one of three ways:

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

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

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

Response fields:

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

Example response

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

Error responses:

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

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

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

Error responses:

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

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

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

Error responses:

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

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

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

Error responses:

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

Get device config schema DRAFT

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

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

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

Required role: superadmin, admin, user

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

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

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

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

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

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

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

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

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

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

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

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

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

Error responses:

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

Get device default config DRAFT

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

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

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

Required role: superadmin, admin, user

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

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

Error responses:

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

Estimate device battery life DRAFT

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

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

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

Required role: superadmin, admin, user

Request body

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

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

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

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

Response shape

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

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

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

estimators.battery fields

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

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

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

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

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

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

Mains-powered (battery_reason: "mains_powered"):

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

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

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

Unknown battery code (battery_reason: "unknown_battery_code"):

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

Warnings vocabulary. The strings in warnings are not enumerated — they are human-readable messages aimed at config-UI admins. Typical patterns:

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

Error responses:

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

Product Catalog APIs DRAFT

Get battery codes DRAFT

Method Location
GET product-catalog/battery-codes

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

Required role: superadmin, factory, admin, user

Example response (200)

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

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

Theme Configuration APIs DRAFT

Get default templates DRAFT

Method Location
GET theme-config/default-templates

Get default email and SMS templates from system configuration.

Example response

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

Preview template DRAFT

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

Preview a rendered template with example variables.

Example request

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

Success response

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

Send test message DRAFT

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

Send a test email or SMS message.

Example request

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

Success response

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

List themes DRAFT

Method Location
GET theme-config/themes

List available theme configurations. Admin users see only their own theme.

Example response

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

Get theme DRAFT

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

Get configuration for a specific theme.

Example response

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

Update theme DRAFT

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

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

Validation constraints:

Example request

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

Device Problem Monitoring APIs DRAFT

Get active problems DRAFT

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

Get all active device problems. Admin users see only confirmed problems with critical/error severity.

Example response

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

Filter values:

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

Get problem history DRAFT

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

Get historical device problems within a time range.

Example response

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

Data Types Reference

Timestamp Format

All timestamps use ISO 8601 format with timezone:

2024-06-01T10:30:00Z

User Roles

Role Description
user Regular user access to assigned locations
admin Administrative access within assigned scope

Problem Types

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

Problem Severity Levels

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

Problem Manifestation

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

Revision history

Version Date Author Description
0.1 4.2.2021 J. Ratilainen Initial draft
0.2 11.2.2021 J. Ratilainen Add api-keys
0.2.1 20.4.2021 J. Ratilainen Clarify units
0.2.2 18.5.2021 J. Ratilainen Add pressure difference
0.3.0 8.9.2021 J. Ratilainen Add acceleration
0.4.0 20.3.2023 J. Ratilainen Add GPS coordinates
0.5.0 2.4.2023 J. Ratilainen Add shock
0.6.0 19.2.2024 J. Ratilainen Add dewpoint, absolute_humidity and tvoc_density
0.7.0 26.9.2024 J. Ratilainen Add support for extra quantities with _ 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/superadmin only). lastlogin reinterpreted as “latest activity” — the greater of credential login and mobile-session refresh. Applies to GET /user, GET /user/{id}, and GET /whoami; “Get user” response also documents active and password-reset-pending for parity with the list endpoint.
0.23.0 27.4.2026 J. Ratilainen Install endpoint accepts optional end-current-installation? flag that atomically closes the device’s current open installation before creating the new one (move-device flow).
0.23.1 27.4.2026 J. Ratilainen GET location/{id}/devices response now includes product-name (was missing from the example despite the 0.20.0 promise).
0.24.0 29.4.2026 J. Ratilainen Add device default-config endpoint (GET device/{device-id}/default-config); admin/user clients no longer need to guess defaults when populating config forms.
0.25.0 30.4.2026 J. Ratilainen Add device downsampled observations endpoint (observation/read-v2/device/{id}/downsampled); LTTB-downsampled time series for a single device.
0.26.0 4.5.2026 J. Ratilainen Add Location Notes (CRUD) under Extended User APIs: time-pinned annotations per location with shared/private visibility, author-only edits/deletes for private notes, and author-only visibility flips into or out of private.
0.27.0 18.5.2026 J. Ratilainen Add battery-life estimator: new POST device/{id}/usage-estimate, new GET product-catalog/battery-codes (Product Catalog APIs section), and four additive top-level fields on the device/{id}/config-schema response (power_profile, power_profile_reason, battery, battery_reason).
0.28.0 2.6.2026 J. Ratilainen GET location/{id}/csv is now streamed (no size limit) and gains optional query params: quantities, compact, utc, local, tz, separator, decimal, bom, preview. Defaults unchanged.
0.29.0 9.6.2026 J. Ratilainen last-quantities (with include-device-info=true) now returns a location-level device object sourced from the current installation, present even when the device has no in-scope measurements. Corrected the note that wrongly claimed an empty values map means no device is installed.
0.30.0 16.6.2026 J. Ratilainen API keys can be created read-only: POST api_key accepts an optional read-only boolean, GET api_key returns read_only per key, and read-only keys are rejected (403) on any mutating HTTP method.
0.31.0 2.7.2026 J. Ratilainen Added notable device settings: admin/superadmin responses expose a notable-flags array ({key, tone, params}) highlighting attention-worthy config (capillary tubing, dense interval, power save off). Present on the device configuration read and the last-quantities device object; opt-in on the device list via include-notable-flags=true.
0.32.0 10.8.2026 J. Ratilainen Added Device Live View: POST/GET lwm2m/device/endpoint/{endpoint}/live-view[/...] temporarily drops a device’s interval to its schema floor for near-real-time monitoring, auto-reverting after an idle timeout or hard deadline. PUT device configuration gains an optional expected-version optimistic-concurrency guard (409 on mismatch) and now validates config.i against the device’s config-schema interval range (400 if out of range; previously unvalidated).
0.33.0 26.8.2026 J. Ratilainen Documented location/{id}/observations/downsampled (LTTB points plus window min/max/mean, no range limit) and added a Choosing an endpoint guide to Reading Observation Data. Clarified that observations/downsampled-v2 is a points-only variant; it now serves ranges of any length. Corrected the aggregates response: min/max are whole buckets selected by extreme value (not by average), avg is now weighted by each bucket’s observation count, the end bound is exclusive, and samples selects the bucket period as well as the LTTB target. The location downsampled endpoints now cap samples at 2000, matching the device one. All three downsampled endpoints may return truncated: true when a range is too large to read in full.
0.34.0 4.9.2026 J. Ratilainen Added the locate sound command: c accepts "b" (schema key locate_sound), which makes the device play a chime for about a minute so it can be found. Documented that the device re-runs a stored c on every configuration version bump, so read-modify-write clients must clear it themselves.
0.35.0 9.9.2026 J. Ratilainen Documented enable bit 21 (spl_pause_during_fan), previously listed as unused: the device pauses sound level measurement while the particle sensor fan runs, so readings no longer include the device’s own fan noise. Set by default. At intervals short enough to keep the fan running continuously the device reports no sound level at all. Updated the bitmask example to keep the bit set.
0.35.1 18.9.2026 J. Ratilainen Documented the group-access field on List devices: the periods during which each device is assigned to the admin’s group. The field was already returned but not documented.

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