Client integration guide · v1

Sensor Data API

Discover probes, read current conditions, and download timestamped measurements. Start with a working example or test a request right here.

1. Get connected

Create a client token

An administrator opens Settings → Client API and creates a token. Copy it when shown; it is displayed once.

Save the token privately

Use an environment variable, Colab Secrets, or the masked tester below. Send it in the Authorization header.

Discover and read

List sensors to find IDs, then request a snapshot or history. Use a probe with recent data for your first test.

Base URL: https://jk.cyberlative.com/api/v1/ml

Use the host supplied by your administrator if different. The built-in tester always calls the website you are viewing.

Authorization: Bearer <client token>

Client tokens are read-only, can access all sites, expire after 90 days, and are limited to 60 requests per minute. Device upload tokens and dashboard session cookies do not authenticate the client data API. This guide covers reading data; device uploads use a separate API.

Try the API

Build a data URL, copy a ready-to-run example, and test it here. For five days of data from three sensors: discover sensors, tick three, choose Last 5 days, then copy the URL or fetch the data.

Build URLs without a token by entering IDs manually. To discover or fetch data, get a token from Settings → Client API (ADMIN only). The token stays in this page’s memory and is sent only in the authorization header to this website. It is never included in copied URLs or code. Tokens can read all sites.

1. Choose the data

Individual readings ordered by sensor ID, then measurement time. Use hourly history for compact min/average/max summaries.

2. Select sensors

Sensors · 0 selected / 100 maximum

Site and device filters (optional)

Filters intersect: a sensor must match every populated filter. Leave sensor IDs empty to include every sensor in the selected sites/devices. Each filter accepts up to 100 IDs; the server allows up to 500 matching sensors. Empty filters on Sensors or Latest values mean all sensors, up to that limit.

3. Set the time range

A day is 24 hours. Choosing a duration captures fixed UTC timestamps; they stay fixed while copying or paging. Use Refresh to now to generate a new window.

From timestamp: Choose a start time

To timestamp (exclusive): Choose an end time

Enter exact ISO timestamps

History includes the start and excludes the end. Raw readings are normally retained for 13 months; the API returns the earliest available timestamp if your range is too old. Time filters use measurement time, except Since, which uses server receipt time.

Shared across all selected sensors, not per sensor.

Tester setting only; not part of the API URL.

Pagination cursor (optional)

Keep the original selection, range and resolution when continuing. Changing request parameters clears the cursor.

Select sensors, devices, or sites to build a readings URL.

Copy this URL into your application, Postman, or the examples below. Protected endpoints also require the Authorization: Bearer header; opening the URL directly in a browser will return 401. The URL describes one page; follow nextCursor for the entire range.

Import an existing request URL

Only supported parameters are accepted. Never put a token in the URL.

All requests are GETs and count toward the token’s 60 requests/minute limit. A multiple-page run stops at your page limit or before exceeding 50,000 rows. Partial results include a continuation URL so you can resume.

Add a Client API token above to send this request. You can still build and copy URLs.

Run client demo checks

Checks authentication, live freshness, history, pagination, and expected validation errors with up to eight read requests. Uses your sensor/site/device selection (or discovers one sensor), and the From/To window. Set the window using Raw history above. Empty history or offline telemetry is marked skipped. This does not prove that a whole historical range has been exported.

2. Copy and run: Python or Google Colab

This complete example asks for your token privately, discovers a sensor, prints its latest values, downloads every page from the last 24 hours, and saves readings.csv. It handles an empty result and respects Retry-After on rate limits.

Google Colab setup
Colab — setup cell
# In Colab, add JK_ML_API_TOKEN in Secrets (key icon), enable notebook access.
%pip -q install requests
from google.colab import userdata
import os
os.environ["JK_ML_API_TOKEN"] = userdata.get("JK_ML_API_TOKEN")
# Now paste and run the complete Python example below in the next cell.

For a local terminal, install the dependency first:

terminal
python3 -m pip install requests
python — complete example
# Python 3.10+ / Google Colab. Install first: pip install requests
import csv
import getpass
import os
import time
from datetime import datetime, timedelta, timezone
import requests

BASE_URL = os.environ.get("JK_API_BASE_URL", "https://jk.cyberlative.com/api/v1/ml").rstrip("/")
TOKEN = os.environ.get("JK_ML_API_TOKEN") or getpass.getpass("Client API token: ")
HEADERS = {"Authorization": "Bearer " + TOKEN.strip()}

def get(path, params=None):
    for attempt in range(3):
        response = requests.get(BASE_URL + path, headers=HEADERS,
                                params=params, timeout=30, allow_redirects=False)
        if response.status_code == 429 and attempt < 2:
            time.sleep(max(1, int(response.headers.get("Retry-After", "60"))))
            continue
        response.raise_for_status()
        if response.status_code != 200:
            raise RuntimeError("Expected HTTP 200; check BASE_URL uses the final HTTPS address")
        return response.json()

sites = get("/sites")["sites"]
sensors = get("/sensors")["sensors"]
print("Sites:", len(sites), "Sensors:", len(sensors))
if not sensors:
    raise SystemExit("No sensors configured. Ask the administrator to add one.")

# Choose an enabled sensor. Set JK_SENSOR_ID to use a known active probe.
sensor = next((s for s in sensors if s["enabled"]), sensors[0])
SENSOR_ID = os.environ.get("JK_SENSOR_ID", sensor["id"])
print("Selected sensor ID:", SENSOR_ID)
print("Latest values:", get("/snapshot", {"sensorIds": SENSOR_ID}))

end = datetime.now(timezone.utc)
params = {"sensorIds": SENSOR_ID, "from": (end - timedelta(days=1)).isoformat(),
          "to": end.isoformat(), "resolution": "raw", "limit": 2000}
rows = []
while True:
    page = get("/readings", params)
    rows.extend(page["data"])
    if not page["hasMore"]:
        break
    params["cursor"] = page["nextCursor"]  # Keep the original filters/from/to.

print("Readings in the last 24 hours:", len(rows))
for row in rows[:5]:
    print(row)
if rows:
    with open("readings.csv", "w", newline="", encoding="utf-8") as output:
        writer = csv.DictWriter(output, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)
    print("Saved readings.csv")
else:
    print("No readings in this window. Choose another sensor or a longer period.")

Locally, save as jk_client_api_demo.py and run python3 jk_client_api_demo.py. In Colab, paste into a cell after setup. Optional environment variables: JK_SENSOR_ID chooses a specific sensor; JK_API_BASE_URL changes the host (include /api/v1/ml). The script writes the CSV in your current working directory.

3. Copy and run: cURL

For macOS/Linux with bash or zsh, curl, and Python 3. Run setup once in the same terminal as the requests. It generates a fresh UTC time window and chooses a sensor ID, so you do not need to edit timestamps.

terminal — run setup first
# macOS/Linux, bash or zsh; requires curl and Python 3.
# The token prompt is hidden and the token is not written into shell history.
export JK_ML_API_TOKEN="$(python3 -c 'import getpass; print(getpass.getpass("Client API token: "))')"
export BASE_URL="https://jk.cyberlative.com/api/v1/ml"

# Confirm the service and discover sensor IDs.
curl --silent --show-error "$BASE_URL" | python3 -m json.tool
curl --silent --show-error "$BASE_URL/sensors" \
  --header "Authorization: Bearer $JK_ML_API_TOKEN" | python3 -m json.tool

# Select the first enabled sensor automatically; set SENSOR_ID manually to choose another.
export SENSOR_ID="$(curl --silent --show-error --fail "$BASE_URL/sensors" \
  --header "Authorization: Bearer $JK_ML_API_TOKEN" | python3 -c 'import json,sys; s=json.load(sys.stdin)["sensors"]; print(next((x["id"] for x in s if x["enabled"]), s[0]["id"] if s else ""))')"
# If SENSOR_ID is empty, configure a sensor before continuing.
export FROM="$(python3 -c 'from datetime import datetime,timedelta,timezone; print((datetime.now(timezone.utc)-timedelta(days=1)).isoformat())')"
export TO="$(python3 -c 'from datetime import datetime,timezone; print(datetime.now(timezone.utc).isoformat())')"

List sites

HTTP 200 with sites, timezone, and device/sensor/chamber counts.

bash / zsh
curl --silent --show-error "$BASE_URL/sites" \
  --header "Authorization: Bearer $JK_ML_API_TOKEN" | python3 -m json.tool

Latest sensor values

HTTP 200 with sensors. Compare tempC, rhPct, ts, quality, and isOnline to the dashboard.

bash / zsh
curl --silent --show-error --get "$BASE_URL/snapshot" \
  --header "Authorization: Bearer $JK_ML_API_TOKEN" \
  --data-urlencode "sensorIds=$SENSOR_ID" | python3 -m json.tool

Raw history

HTTP 200 with data, hasMore, and nextCursor. Times are generated by the setup above.

bash / zsh
curl --silent --show-error --get "$BASE_URL/readings" \
  --header "Authorization: Bearer $JK_ML_API_TOKEN" \
  --data-urlencode "sensorIds=$SENSOR_ID" \
  --data-urlencode "from=$FROM" --data-urlencode "to=$TO" \
  --data-urlencode "resolution=raw" --data-urlencode "limit=5" | python3 -m json.tool

Hourly history

Returns min/average/max, sample_count and gap_seconds. Full UTC hour buckets may overlap a partial-hour start.

bash / zsh
curl --silent --show-error --get "$BASE_URL/readings" \
  --header "Authorization: Bearer $JK_ML_API_TOKEN" \
  --data-urlencode "sensorIds=$SENSOR_ID" \
  --data-urlencode "from=$FROM" --data-urlencode "to=$TO" \
  --data-urlencode "resolution=hourly" --data-urlencode "limit=2000" | python3 -m json.tool

Missing token test

Expected HTTP 401. This is a successful access-control test.

bash / zsh
curl --include "$BASE_URL/sensors"

Invalid limit test

Expected HTTP 400 with a message about limit. Requires a valid token and selected sensor.

bash / zsh
curl --include --get "$BASE_URL/readings" \
  --header "Authorization: Bearer $JK_ML_API_TOKEN" \
  --data-urlencode "sensorIds=$SENSOR_ID" \
  --data-urlencode "from=$FROM" --data-urlencode "to=$TO" \
  --data-urlencode "limit=-1"

When finished, run unset JK_ML_API_TOKEN. The tester can also generate a cURL command for any request you configure.

4. Copy and run: JavaScript

Node.js 20+ has fetch built in; no package installation is needed. Set the token with the terminal setup above, save this as demo.mjs, and run node demo.mjs. It discovers a sensor and reads all history pages from the last day.

javascript — Node.js
// Node.js 20+. Save as demo.mjs and run: node demo.mjs
// First set JK_ML_API_TOKEN with the terminal setup above.
const base = process.env.JK_API_BASE_URL || 'https://jk.cyberlative.com/api/v1/ml';
const token = process.env.JK_ML_API_TOKEN;
if (!token) throw new Error('Set JK_ML_API_TOKEN before running.');
async function get(path, params = {}) {
  const url = new URL(base.replace(/\/$/, '') + path);
  url.search = new URLSearchParams(params).toString();
  const response = await fetch(url, {
    headers: { Authorization: 'Bearer ' + token },
    signal: AbortSignal.timeout(30000), redirect: 'error',
  });
  if (!response.ok) throw new Error('HTTP ' + response.status + ': ' + await response.text());
  return response.json();
}
const { sensors } = await get('/sensors');
if (!sensors.length) throw new Error('No sensors configured.');
const sensor = sensors.find(s => s.enabled) || sensors[0];
console.log(await get('/snapshot', { sensorIds: sensor.id }));
const to = new Date();
const params = { sensorIds: sensor.id, from: new Date(to.getTime() - 86400000).toISOString(),
  to: to.toISOString(), limit: '2000', resolution: 'raw' };
const rows = [];
while (true) {
  const page = await get('/readings', params);
  rows.push(...page.data);
  if (!page.hasMore) break;
  params.cursor = page.nextCursor;
}
console.log('Rows:', rows.length, 'First five:', rows.slice(0, 5));

Use a server or Node.js for integrations on other websites. The API does not enable cross-origin browser access. The tester on this documentation page works on the same origin. On HTTP 429, wait for the Retry-After header before rerunning.

5. Endpoint and parameter reference

All endpoints use GET and return JSON. Paths below are relative to /api/v1/ml.

PathTokenPurpose / response
/NoPublic index: name, version, authentication, endpoints, documentation.
/sitesYessites array with id, slug, name, timezone and _count (devices, sensors, chambers).
/sensorsYessensors array with id, code, label, kind, enabled, site, device and chamber (nullable).
/snapshotYesgeneratedAt and sensors: most recent usable values, message status and freshness.
/readingsYesresolution, data, nextCursor and hasMore. Raw or hourly history; raw incremental polling.
ParameterUsed byRules
sensorIds / deviceIds / siteIdssensors, snapshot, readingsComma-separated IDs; at most 100 per selector. At least one selector is required for readings. Multiple selectors are intersected. At most 500 sensors may match.
from + toHistorical readingsProvide both as ISO 8601 timestamps with a timezone. from must be earlier than to. Raw intervals include from and exclude to.
sinceIncremental readingsInsertion-time start, ISO 8601 with timezone. Use instead of from/to. Only raw resolution is supported.
resolutionreadingsraw (default) or hourly. Hourly timestamps mark UTC bucket starts; the entire bucket overlapping from is included.
limitreadingsInteger 1–5,000; default 2,000. Limits rows, not sensors.
cursorreadingsOpaque nextCursor from the previous response. Repeat the original selectors, time parameters and resolution.

Use sensor.id for sensorIds, sensor.device.id for deviceIds, and site.id for siteIds. A sensor code such as DEMO-T1 or a device nodeId is a human-readable label, not the database ID expected by these filters.

Example timestamps: 2026-09-24T08:00:00Z and 2026-09-24T13:30:00+05:30 refer to the same instant. URL-encode parameters (cURL --data-urlencode, requests params, or URLSearchParams). Raw history is retained for 13 months by default; older requests return rawAvailableFromUtc. Hourly rollups are retained longer and may lag new readings until the next rollup pass.

6. Understand the responses

The JSON below is illustrative, not live data. Snapshot examples omit some location and display fields. Raw history uses snake_case; snapshot fields use camelCase.

Latest values — snapshot
example JSON (illustrative)
{
  "generatedAt": "2026-09-24T08:00:05.000Z",
  "sensors": [
    {
      "id": "sensor-demo-id",
      "code": "DEMO-T1",
      "tempC": 5,
      "rhPct": 50,
      "ts": "2026-09-24T08:00:00.000Z",
      "lastMessageAt": "2026-09-24T08:00:00.000Z",
      "quality": "OK",
      "isOnline": true,
      "ageSeconds": 5,
      "staleAfterSeconds": 1800
    }
  ]
}
Individual measurements — raw history
example JSON (illustrative)
{
  "resolution": "raw",
  "data": [
    {
      "sensor_id": "sensor-demo-id",
      "timestamp_utc": "2026-09-24T08:00:00.000Z",
      "temp_c": 5,
      "rh_pct": 50,
      "dew_point_c": -4.58,
      "quality": "OK",
      "ts_source": "DEVICE",
      "backfilled": false
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
Aggregated measurements — hourly history
example JSON (illustrative)
{
  "resolution": "hourly",
  "data": [
    {
      "sensor_id": "sensor-demo-id",
      "timestamp_utc": "2026-09-24T08:00:00.000Z",
      "temp_min_c": 4.8,
      "temp_avg_c": 5,
      "temp_max_c": 5.2,
      "rh_min_pct": 49,
      "rh_avg_pct": 50,
      "rh_max_pct": 51,
      "dew_point_avg_c": -4.58,
      "sample_count": 120,
      "gap_seconds": 0,
      "quality": null,
      "ts_source": null,
      "backfilled": null
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
FieldMeaning
temp_c / tempCCalibrated temperature in °C. null means unavailable, not zero.
rh_pct / rhPctRelative humidity in percent. May be null.
dew_point_c / dewPointCDerived dew point in °C when temperature and RH are usable.
timestamp_utcEvent time for raw data; start of the UTC hour for hourly data.
ts / lastMessageAtSnapshot: last usable measurement time / most recent message time. A fault may be newer than the displayed value.
qualityOK, CRC_ERROR, NAN, DISCONNECTED or OUT_OF_RANGE. Hourly data uses null. Snapshot quality can also be null before any message.
ts_sourceDEVICE when the device clock is trusted; SERVER when receipt time replaced an unusable clock. null for hourly data.
backfilledtrue when the reading was outside the live window. Historical data is stored without replacing the live value. null for hourly data.
isOnline / ageSecondsSnapshot availability and age of the last usable reading. Check these and ts before treating values as current.
sample_count / gap_secondsHourly count and missing-coverage estimate based on a nominal 30-second cadence. Fault messages can still contribute to sample_count.
data / nextCursor / hasMoreCurrent page / checkpoint for continuation / whether more rows are immediately available.

A 200 response with an empty array is valid: the IDs may not match, or there may be no data in that window. The API does not invent zero-valued readings to fill gaps.

7. Pagination and live polling

  1. For history, choose IDs and a fixed from/to interval, then fetch the first page.
  2. While hasMore is true, request the nextCursor with exactly the same filters and time range. Stop once hasMore is false.
  3. For incremental reads, start with since and raw resolution. Keep both the original since and the returned cursor, even after an empty page.
  4. After draining available pages, wait 30–60 seconds before polling again. On 429, respect Retry-After. When restarting with an overlapping window, deduplicate by (sensor_id, timestamp_utc).
python — run after the quickstart
# Run after the Python quickstart; reuses get() and SENSOR_ID.
# Stop with Ctrl+C / the Colab stop button. No device writes are made.
import time
from datetime import datetime, timezone
params = {"sensorIds": SENSOR_ID, "since": datetime.now(timezone.utc).isoformat(),
          "resolution": "raw", "limit": 2000}
cursor = None
try:
    while True:
        while True:
            page_params = {**params, **({"cursor": cursor} if cursor else {})}
            page = get("/readings", page_params)
            for row in page["data"]:
                print(row)
            # In production, commit processed rows and this checkpoint together.
            # Keep the original since value even across empty polls.
            cursor = page["nextCursor"]
            if not page["hasMore"]:
                break
        time.sleep(30)
except KeyboardInterrupt:
    print("Polling stopped.")

Incremental ordering follows insertion time, so delayed backfills are included even if their measurement timestamp is old. Snapshot is a current-state view; it is not a substitute for incremental collection. Persist processed rows and their checkpoint together in a production integration.

8. Show a client that it works

Use the tester’s Run demo checks button, or import the downloadable Postman collection. A dedicated demo token and a sensor with at least six readings in the selected window make pagination easy to demonstrate.

CheckExpected result
Open the public index200 with API metadata; no readings exposed.
Request sensors without a token401. Access-control check passes.
Discover sensors with your token200 and IDs/labels you can match to the dashboard.
Read a snapshot twice, 30 seconds apartA recent ts advances when the device reports. Temperature may stay unchanged. An offline sensor is not evidence of live telemetry.
Read a five-row history page and continue200; next page has no repeated first-page rows. Skipped when there are not enough readings.
Send limit=-1 or reversed dates400 and a useful validation message.
Optionally revoke the dedicated demo tokenSubsequent requests return 401. Do this manually in Settings; it ends that token’s access.

In Postman, import the collection, create a local environment variable named apiToken, mark it sensitive, and run requests in order. Request 01 creates a fresh 24-hour window; request 04 chooses a sensor if none is set. The pagination assertion is skipped if no second page exists. Capture status codes, UTC timestamps and Test Results for your client; keep the token hidden.

Importable Postman collection

9. Troubleshooting

SymptomWhat to do
400 Bad RequestRead error in the JSON body. Check database IDs, paired from/to, timezone suffixes, resolution, cursor mode, and limit (1–5,000).
401 UnauthorizedUse a Client API token. Check that it has not expired or been revoked. Dashboard login and device tokens do not grant this API access.
403 on credential managementOnly administrators can create/revoke client credentials in Settings → Client API. This is separate from GET data requests.
429 Too Many RequestsWait the Retry-After seconds. Stay below 60 requests/minute per token across all clients sharing that token.
200 but empty dataDiscover IDs again; multiple selectors intersect. Widen the time window or choose a known reporting sensor.
Old values / isOnline=falseCheck the device and lastMessageAt/ts. API reachability does not prove that new telemetry is arriving.
Raw readings outside retentionUse rawAvailableFromUtc to choose a recent interval, or request resolution=hourly for older data.
Network error / timeout / non-JSON responseCheck the final HTTPS host, network and proxy. The tester times out after 30 seconds. Do not bypass certificate validation.

For support, share the endpoint, query parameters, HTTP status and request time. Remove the bearer token from screenshots, notebook exports and copied request headers.