Machine-readable documentation
QiPort — Integration guide for AI agents
Audience: an AI coding agent that has been asked to build an application against QiPort. This document is written to be followed top to bottom. Every endpoint carries a status. Generate code only against endpoints marked Live
mqtt.qiport.eu:8883 requires a client certificate belonging to a device
or an Edge Agent. Never put broker credentials, a device certificate, or a broker hostname
into an application. Applications use HTTPS + OAuth2, and (when it ships) SSE. If your
generated code imports an MQTT library, you have made a mistake.
403 forbidden with a reasons[]
array is a normal, expected outcome that your UI must render, not an error to retry or to
surface as a crash.
You cannot make a device join. There is no way to put the SLZB-06P10 coordinator into
permit-join mode, trigger a Zigbee network join, or provision, claim or adopt a device over the
API today. Do not generate a call to /v1/pairing, /v1/permit-join,
/v1/devices/discover or /v1/provisioning — none of them exist and all
of them will 404.
"Find Tuya devices" means GET /v1/devices, then filter. A device must already
exist in the registry before an application can see it. If the registry is empty, the correct
behaviour is an empty state that says devices must be onboarded out-of-band — it is not a bug
in your code, and retrying will not populate it.
| Field | Value |
|---|---|
| API base URL | https://api.qiport.eu |
| Docs base URL | https://docs.qiport.eu |
| MQTT broker | mqtt.qiport.eu:8883 — devices and Edge Agents only, never applications |
| Auth | OAuth2 client_credentials, bearer token, expires_in 3600 |
| Data residency | EU |
| Document date | 2026-08-05 |
Status vocabulary used throughout
| Status | Meaning | What you may generate |
|---|---|---|
| Live | Implemented, deployed, tested. | Normal code. |
| Building | Code written, not deployed. Calling it today fails. Shapes below are provisional and may change before release. | Only feature-detected code that handles "not deployed" as a normal branch. |
| Not built | Roadmap. No implementation, no shape, no endpoint. | Nothing. Do not invent a URL. |
1. Read this first
1.1 There is no pairing API. You cannot make a device join.
This is the single most important fact in this document.
If a human developer asked you to "find Tuya devices", what you can do today is list devices that already exist in the QiPort registry. You cannot:
- put the SLZB-06P10 coordinator into permit-join mode,
- trigger a Zigbee network join,
- provision, claim, or adopt a new device,
- discover a device that has not already been onboarded.
There is no endpoint for any of that. Pairing / permit-join is NOT BUILT. Do not
generate a call to /v1/pairing, /v1/permit-join, /v1/devices/discover,
/v1/provisioning, or any similar path — none of them exist and all of them will 404.
"Find Tuya devices" therefore means: GET /v1/devices, then filter the result to the
devices whose identity fields look like Tuya hardware. Section 4 shows how, and section 4.3
explains why you should mostly stop caring which vendor made the device.
If the registry is empty, the correct application behaviour is an empty state that says devices must be onboarded out-of-band. It is not a bug in your code, and retrying will not populate it.
1.2 Three rules that must never be violated
Rule 1 — The application never connects to MQTT.
mqtt.qiport.eu:8883 requires a client certificate belonging to a device or an Edge
Agent. Never put broker credentials, a device certificate, or a broker hostname into an
application. Applications use REST, and (when it ships) SSE. If your generated code
imports an MQTT library, you have made a mistake.
Rule 2 — The SLZB-06P10 is never exposed to the internet.
The coordinator speaks Zigbee to devices and a local protocol to the QiPort Edge Agent on
the same LAN. The Edge Agent makes an outbound mTLS MQTT connection to the cloud.
Nothing dials in. Do not generate code that connects to the coordinator's IP, its web UI,
or its TCP/serial port from an application, and do not suggest port-forwarding it.
Rule 3 — Never assume an actuator is controllable.
A capability being writable in the catalogue does not mean this device may be written.
A per-device write grant must exist. 403 forbidden with a reasons[] array is a normal,
expected outcome that your UI must render, not an error to retry or to surface as a crash.
1.3 Architecture you are writing against
Tuya Zigbee device --(Zigbee)--> SLZB-06P10 --(local LAN)--> QiPort Edge Agent
|
outbound mTLS MQTT only
v
QiPort Cloud (EU)
|
HTTPS + OAuth2 v
Your applicationYour application occupies exactly one position in that diagram: the bottom box. It talks
to https://api.qiport.eu and to nothing else in this system.
1.4 There is no SDK
QiPortKit is NOT BUILT. There is no @qiport/* package on npm, no Python package, no
Go module. If you generate import { QiPort } from '@qiport/sdk', the install will fail.
Write a plain HTTP client. A complete one is in example-app/qiport-client.ts next to this
document; copy it rather than inventing another.
1.5 Response key casing is mixed. Do not normalise it.
This API does not use one casing convention throughout. Use the exact key shown for each
endpoint. This is the second most common source of generated code that compiles and then
returns undefined at runtime.
| Context | Convention | Examples |
|---|---|---|
Query parameters on GET /v1/devices | snake_case | site_id, status, model |
| Device object fields | mixed; hub linkage is snake_case | parent_hub_id, manufacturer, product_model |
| State response envelope | camelCase | deviceId, connectivity, lastSeenAt |
| State entries | camelCase | capability, value, unit, quality, deviceTime, serverTime |
| Telemetry response envelope | camelCase | deviceId, resolution, aggregation, points |
| Telemetry point entries | snake_case | capability_key, bucket, value, samples |
Note the last two rows: one response body contains a camelCase envelope wrapping
snake_case rows. That is not a typo in this document. Write p.capability_key, not
p.capabilityKey.
1.6 Response envelopes that are not pinned here
Where this document does not show a literal example of a response body, do not guess the envelope. Read defensively. Specifically:
- The list envelope of
GET /v1/devices,GET /v1/sites,GET /v1/bindingsandGET /v1/catalog/capabilitiesis not pinned in this document. Accept an array under any ofdevices/sites/bindings/capabilities/items/data, or a bare top-level array, and take the cursor fromnextCursor/next_cursor/cursor. example-app/qiport-client.ts contains a tolerant reader that does this; reuse it. GET .../telemetry?resolution=raw— the aggregate shape (bucket,samples) is documented for1m|5m|1h|1d. The raw shape is not pinned here. If you need raw points, inspect one live response before writing parsing logic, or use1mand treat it as raw enough for a chart.
Reading defensively costs five lines. Guessing costs a runtime failure in a customer's house.
2. Authenticate Live
2.1 The token call
POST /v1/oauth/token, OAuth2 client_credentials grant (RFC 6749 §4.4).
| Item | Value |
|---|---|
| Method / path | POST https://api.qiport.eu/v1/oauth/token |
| Content-Type | application/x-www-form-urlencoded |
| Body fields | grant_type=client_credentials, client_id, client_secret, scope |
| Token lifetime | expires_in = 3600 seconds |
| Refresh token | None. client_credentials has no refresh token — request a new access token. |
| Failure | 401 with invalid_client for a bad secret. |
Verify once, then trust: this document specifies credentials in the form body. If a particular deployment requires HTTP Basic (
Authorization: Basic base64(id:secret)) instead, the token call returns 401invalid_clientwith correct credentials. That is the one-line switch noted inqiport-client.ts. Everything else in this section is pinned.
curl:
curl -sS -X POST https://api.qiport.eu/v1/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$QIPORT_CLIENT_ID" \
--data-urlencode "client_secret=$QIPORT_CLIENT_SECRET" \
--data-urlencode 'scope=devices:read telemetry:read catalog:read'Response:
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHAtaGl2ZS1kYXNoIn0.SIG",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "devices:read telemetry:read catalog:read"
}Use it as Authorization: Bearer <access_token> on every subsequent request except
GET /health.
2.2 TypeScript
type TokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
scope?: string;
};
export async function fetchToken(
baseUrl: string,
clientId: string,
clientSecret: string,
scope: string,
signal?: AbortSignal,
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope,
});
const res = await fetch(new URL('/v1/oauth/token', baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
signal,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`token request failed: HTTP ${res.status} ${text}`);
}
return (await res.json()) as TokenResponse;
}2.3 Refresh strategy
Cache the token in memory with its expiry and re-request when it is close to expiring. Concretely:
- Store
expiresAt = now + expires_in * 1000. - Treat the token as expired 60 seconds early (
now >= expiresAt - 60_000). Clock skew and a slow request are enough to make a token that "has 2 seconds left" arrive already dead. - Keep exactly one in-flight token request. Ten concurrent API calls that all see an expired token must not fire ten token requests — share one promise.
- On a
401from a normal API call, discard the cached token, re-authenticate once, and replay the request. If the replay is also401, fail. Never loop: a revoked client would otherwise produce an infinite retry storm against the token endpoint and then a429.
All four behaviours are implemented in example-app/qiport-client.ts.
2.4 Where the secret lives
| Placement | Verdict |
|---|---|
| Server-side environment variable / secret manager | Correct. |
| Backend-for-frontend that holds the secret and proxies to QiPort | Correct. |
| Browser JavaScript bundle | Never. The bundle is public. |
| Mobile app binary | Never. Extractable by decompilation. |
Committed .env file, source, or repository | Never. |
| Query string of any request | Never. It lands in proxy and access logs. |
client_credentials identifies a service, not an end user. If your application has end
users, the correct topology is: user → your backend (your own auth) → QiPort with the
service credential. Do not generate a frontend that talks to api.qiport.eu directly.
2.5 Scopes
Scopes issued today:
| Scope | Grants |
|---|---|
devices:read | GET /v1/devices, GET /v1/devices/:publicId, .../state, GET /v1/sites, GET /v1/bindings |
devices:write | POST /v1/bindings |
telemetry:read | GET /v1/devices/:publicId/telemetry |
telemetry:live | GET /v1/live Building |
catalog:read | GET /v1/catalog/capabilities |
Request the narrowest set your application actually needs.
High-risk scope.
commands:high_riskgates S3 capabilities and is issued only to clients created with it. The older note said these scopes did not exist; that is no longer true. Ignore the sentence below if it survives a cache — the authoritative list iscommands:read,commands:writeandcommands:high_risk. Those scopes are not issued by the token endpoint today. Requesting them may cause the token call itself to fail. Do not add them to ascopestring in code you ship now.
3. Discover devices Live
3.1 List
GET /v1/devices — requires devices:read.
| Parameter | Type | Status | Meaning |
|---|---|---|---|
status | string | Live | Filter by device status. |
model | string | Live | Filter by product model, e.g. TS0201. |
site_id | string | Live | Filter by site. Note the snake_case. |
cursor | string | Live | Opaque continuation token from the previous page. |
limit | integer | Unverified | Page size. Conventional but not pinned in this document; the server may cap or ignore it. Pagination works via cursor regardless — prefer to omit it. |
curl -sS 'https://api.qiport.eu/v1/devices?site_id=site_apiary_north' \
-H "Authorization: Bearer $TOKEN"3.2 Paginate with the cursor
The cursor is opaque. Do not parse it, decode it, or construct one. Loop until the response stops giving you a cursor, and put a hard bound on the loop so a server-side bug cannot turn into an infinite request loop.
export async function listAllDevices(
get: (path: string) => Promise<unknown>,
): Promise<Record<string, unknown>[]> {
const out: Record<string, unknown>[] = [];
let cursor: string | null = null;
let pages = 0;
do {
const qs = cursor === null ? '' : `?cursor=${encodeURIComponent(cursor)}`;
const page = (await get(`/v1/devices${qs}`)) as Record<string, unknown>;
// Envelope is not pinned — see section 1.6.
const rows =
(page['devices'] as unknown) ??
(page['items'] as unknown) ??
(page['data'] as unknown) ??
(Array.isArray(page) ? page : []);
if (Array.isArray(rows)) {
for (const r of rows) {
if (r !== null && typeof r === 'object') out.push(r as Record<string, unknown>);
}
}
const next =
(page['nextCursor'] as unknown) ??
(page['next_cursor'] as unknown) ??
(page['cursor'] as unknown);
cursor = typeof next === 'string' && next.length > 0 ? next : null;
pages += 1;
if (pages > 500) throw new Error('device pagination exceeded 500 pages; aborting');
} while (cursor !== null);
return out;
}3.3 One device
GET /v1/devices/{publicId} — requires devices:read.
publicId is the stable public identifier, e.g. QP-SIM-00001. It is the id used
everywhere in the API and in MQTT topics — it is never the internal database UUID and
never the Zigbee IEEE address.
A device belonging to another tenant returns 404, not 403, so the endpoint cannot be used to enumerate other tenants' fleets. Treat 404 as "not visible to this token", which covers both "does not exist" and "not yours".
3.4 Telling a hub from a child device
| Field | Value | Meaning |
|---|---|---|
parent_hub_id | null or absent | This device is a hub / coordinator, or has no parent. |
parent_hub_id | a device public id | This is a child device reached through that hub. |
The SLZB-06P10 coordinator appears as a device with no parent_hub_id. Every Tuya sensor
joined through it carries that coordinator's public id in parent_hub_id.
const isHub = (d: Record<string, unknown>): boolean => {
const p = d['parent_hub_id'];
return p === null || p === undefined || p === '';
};Group child devices by parent_hub_id when you need to show "which hub is this sensor
behind", and when you need to explain a fleet-wide outage: if every child of one hub goes
offline at once, the hub or its Edge Agent is the suspect, not twenty sensors.
4. Identify a Tuya device Live
4.1 Fields that indicate Tuya hardware
| Field | Typical Tuya value | Notes |
|---|---|---|
manufacturer | _TZ3000_xxxxxxxx, _TZE200_xxxxxxxx, _TZE204_xxxxxxxx, _TYZB01_xxxxxxxx | Reported by the device as the Zigbee manufacturerName. The _TZ/_TY prefix followed by digits and an underscore is the strong signal. |
product_model | TS0201, TS0203, TS011F, TS0601 | Reported as the Zigbee modelID. TS0601 is the generic Tuya-datapoint model and covers dozens of physically different products. |
manufacturer | Tuya, TuYa, _TZ... variants | Some units report a plain vendor string. |
Case is significant. _TZ3000_abcd1234 and TS0201 are case-sensitive vendor identifiers;
QiPort preserves case exactly as the device reported it, because case-folding would collapse
genuinely different models onto one profile. Do not lowercase before comparing, and if
you must be lenient, be lenient explicitly.
A practical, deliberately conservative predicate:
const TUYA_MANUFACTURER_RE = /^_(TZ|TY)[A-Z0-9]*_/i;
const TUYA_MODEL_RE = /^TS\d{4}[A-Z]?$/i;
export function looksLikeTuya(device: Record<string, unknown>): boolean {
const manufacturer = typeof device['manufacturer'] === 'string' ? device['manufacturer'] : '';
const model = typeof device['product_model'] === 'string' ? device['product_model'] : '';
if (TUYA_MANUFACTURER_RE.test(manufacturer)) return true;
if (/^tuya$/i.test(manufacturer.trim())) return true;
if (TUYA_MODEL_RE.test(model)) return true;
return false;
}This is a heuristic, and you should label it as one in the code you generate. It will occasionally miss a rebadged unit and occasionally match a non-Tuya device that happens to use a similar model string. That is acceptable, because — see the next section — almost no application logic should depend on the answer.
4.2 TS0601 is not a product
TS0601 is Tuya's catch-all model for devices that speak Tuya's proprietary datapoint
cluster instead of standard Zigbee clusters. A soil-moisture probe, a radiator valve, a
smart curtain motor and an energy meter can all report TS0601. Model string alone
cannot tell you what a TS0601 device does. Its capability list can.
4.3 Key your logic on capabilities, not on model strings
This is the reason the platform exists. QiPort's Edge Agent and device catalogue already
did the work of turning Zigbee clusters and Tuya datapoints into canonical capability
keys. An application that switches on product_model re-introduces exactly the coupling
the platform removed, and breaks the first time a customer buys the same sensor from a
different vendor.
| Instead of | Do |
|---|---|
if (d.product_model === 'TS0201') | if (caps.has('environment.temperature')) |
if (d.manufacturer.startsWith('_TZ3000')) | if (caps.has('binary.contact')) |
| A per-model parsing table | Read state[] and switch on capability |
| Hard-coded units per model | Read unit from the state entry or the catalogue |
Legitimate uses of manufacturer / product_model: display in a device-detail screen,
support diagnostics, inventory reports, and answering the literal question "which of these
are Tuya". Everything else keys on capabilities.
4.4 The canonical capability catalogue
GET /v1/catalog/capabilities — Live, requires catalog:read. Fetch it once at startup
and cache it; it changes on platform releases, not per request.
| Capability key | Unit | Type | Writable | Safety class |
|---|---|---|---|---|
environment.temperature | C | number | no | S0 |
environment.humidity | %RH | number | no | S0 |
environment.co2 | ppm | number | no | S0 |
environment.pressure | hPa | number | no | S0 |
environment.illuminance | lx | number | no | S0 |
environment.soil_moisture | % | number | no | S0 |
mass.weight | kg | number | no | S0 |
motion.vibration | m/s2 | number | no | S0 |
water.leak | — | boolean | no | S0 |
water.flow | L/min | number | no | S0 |
electrical.voltage | V | number | no | S0 |
electrical.current | A | number | no | S0 |
electrical.power_active | W | number | no | S0 |
electrical.energy_import | Wh | number | no | S0 |
binary.contact | — | boolean | no | S0 |
binary.switch | — | boolean | yes | S1 |
actuator.relay | — | boolean | yes | S2 |
actuator.valve | — | boolean | yes | S2 |
actuator.contactor | — | boolean | yes | S3 |
system.battery_percent | % | number | no | S0 |
system.rssi | dBm | number | no | S0 |
system.lqi | — | number | no | S0 |
system.uptime | s | number | no | S0 |
system.device_temperature | C | number | no | S0 |
system.battery_voltage | V | number | no | S0 |
energy.pv_power | W | number | no | S0 |
energy.load_power | W | number | no | S0 |
energy.grid_power | W | number | no | S0 |
energy.battery_power | W | number | no | S0 |
energy.produced_today | kWh | number | no | S0 |
energy.consumed_today | kWh | number | no | S0 |
evse.power | W | number | no | S0 |
evse.current | A | number | no | S0 |
evse.session_energy | kWh | number | no | S0 |
evse.total_energy | kWh | number | no | S0 |
evse.state | — | text | no | S0 |
vehicle.battery_percent | % | number | no | S0 |
vehicle.range | km | number | no | S0 |
hvac.setpoint_temperature | C | number | no | S0 |
hvac.mode | — | text | no | S0 |
Two of these keys are signed, and two families are counters. energy.grid_power is positive when the house imports and negative when it exports; energy.battery_power is positive when the battery charges. Taking the absolute value of either one reverses what the reading means. And energy.produced_today, energy.consumed_today and evse.total_energy are counters that only rise, so they aggregate with max or last — a mean of a counter is not a quantity. The two daily counters reset at midnight in the site’s own timezone; evse.total_energy never resets.
Safety classes:
| Class | Meaning | Controllable by default |
|---|---|---|
| S0 | Read-only measurement or diagnostic. | n/a — never writable |
| S1 | Low-risk write, e.g. an indicator or a low-power switch. | Yes, subject to scope |
| S2 | Relay or valve. Physical consequence. | No. Grant required. |
| S3 | Mains contactor. High consequence. | No. Grant required, plus additional ceremony. |
For S2 and S3 a grant row must exist, and a database trigger refuses to create one unless
the device's catalogue profile is verified. A device whose profile is still a draft
cannot be armed at all, by any API call, by anyone. Build your UI so that this is a normal
state with an explanation, not an error dialog.
Units come from the platform, but always render the unit you received rather than a
constant you hard-coded — it is the only thing that stays correct when a capability gains a
variant.
5. Read state and history Live
5.1 Latest state
GET /v1/devices/{publicId}/state — requires devices:read. Returns the latest value per
capability. This response shape is pinned.
curl -sS https://api.qiport.eu/v1/devices/QP-SIM-00001/state \
-H "Authorization: Bearer $TOKEN"{
"deviceId": "QP-SIM-00001",
"connectivity": "online",
"lastSeenAt": "2026-08-05T06:11:33.789Z",
"state": [
{
"capability": "environment.temperature",
"value": 35.34,
"unit": "C",
"quality": ["valid"],
"deviceTime": "2026-08-05T06:11:33.001Z",
"serverTime": "2026-08-05T06:11:33.789Z"
}
]
}| Field | Notes |
|---|---|
deviceId | The public id, same value you put in the path. |
connectivity | Connectivity of the device as the platform last observed it. |
lastSeenAt | ISO-8601 UTC. Use it to grey out stale readings. |
state[].capability | A canonical key from section 4.4. Switch on this. |
state[].value | number or boolean depending on the capability. Do not coerce. |
state[].unit | Render this; do not hard-code the unit. |
state[].quality | Array of markers, e.g. ["valid"]. Treat anything that is not ["valid"] as suspect and say so in the UI rather than silently charting it. |
state[].deviceTime | When the device says it measured. May be skewed or missing on battery devices. |
state[].serverTime | When QiPort stored it. Use this for ordering and for "is this stale". |
Two rules for handling values:
- Do not coerce.
binary.contactis a JSON boolean. Do not accept1,"1","true"or truthiness. If you see a non-boolean on a boolean capability, that is a bug worth surfacing, not something to paper over. - A capability absent from
state[]means "no value yet", not zero and not false. A battery sensor that has not reported since onboarding has nosystem.battery_percententry. Render "no data", never0%.
5.2 History
GET /v1/devices/{publicId}/telemetry — requires telemetry:read.
| Parameter | Type | Notes |
|---|---|---|
from | ISO-8601 timestamp | Start of window, inclusive. |
to | ISO-8601 timestamp | End of window. |
capabilities | comma-separated capability keys | Restrict to what you will actually draw. |
resolution | raw | 1m | 5m | 1h | 1d | Bucket width. |
aggregation | e.g. avg, min, max, sum | How samples inside a bucket are combined. Ignored for raw. |
Aggregate response (pinned):
{
"deviceId": "QP-SIM-00001",
"resolution": "1h",
"aggregation": "avg",
"points": [
{ "capability_key": "environment.temperature", "bucket": "2026-08-04T18:00:00.000Z", "value": 34.9, "samples": 60 },
{ "capability_key": "environment.temperature", "bucket": "2026-08-04T19:00:00.000Z", "value": 35.2, "samples": 60 }
]
}points[] entries are snake_case inside a camelCase envelope. See section 1.5.
samples is the number of underlying readings in the bucket. A bucket with
samples: 1 next to buckets with samples: 60 is a gap, not a spike — render it
differently or you will show a customer a temperature cliff that never happened.
5.3 Worked example: choosing resolution for a chart
Pick the resolution from the window length and the pixel width of the chart, not from the user's menu. The goal is roughly one point per pixel column: fewer and the chart is blocky, more and you transferred data the screen cannot show.
| Window | Sensible resolution | Points per capability (approx.) |
|---|---|---|
| Last 1 hour | 1m | 60 |
| Last 6 hours | 1m | 360 |
| Last 24 hours | 5m | 288 |
| Last 7 days | 1h | 168 |
| Last 30 days | 1h | 720 |
| Last 90 days | 1d | 90 |
| Last 1 year | 1d | 365 |
export type Resolution = 'raw' | '1m' | '5m' | '1h' | '1d';
/** Choose the coarsest resolution that still fills `targetPoints` columns. */
export function chooseResolution(
fromIso: string,
toIso: string,
targetPoints = 800,
): Resolution {
const spanMs = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(spanMs) || spanMs <= 0) {
throw new Error(`invalid window: ${fromIso} .. ${toIso}`);
}
const idealBucketMs = spanMs / targetPoints;
const ladder: Array<[Resolution, number]> = [
['1m', 60_000],
['5m', 300_000],
['1h', 3_600_000],
['1d', 86_400_000],
];
for (const [name, ms] of ladder) {
if (idealBucketMs <= ms) return name;
}
return '1d';
}A 24-hour temperature chart for one device:
FROM=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)
TO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
curl -sS -G https://api.qiport.eu/v1/devices/QP-SIM-00001/telemetry \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode "from=$FROM" \
--data-urlencode "to=$TO" \
--data-urlencode 'capabilities=environment.temperature,environment.humidity' \
--data-urlencode 'resolution=5m' \
--data-urlencode 'aggregation=avg'Which aggregation to use:
| Capability kind | Aggregation | Why |
|---|---|---|
| Temperature, humidity, pressure, CO2, illuminance, weight | avg | Continuous quantities. |
electrical.power_active on a "peak load" chart | max | An average hides the peak that trips a breaker. |
electrical.energy_import | max per bucket, then difference between buckets | It is a monotonically increasing meter total. Summing it multiplies your customer's energy bill by the number of samples. |
water.leak, binary.contact, binary.switch | max | Booleans aggregate to "did it happen in this bucket". |
system.battery_percent | min | You care about the worst point in the window. |
6. Bind a device to your domain object Live
6.1 The model
A binding is the join between a QiPort device and an object in your application's domain. It exists so your application never has to keep its own device-to-object mapping table in sync with the fleet.
POST /v1/bindings — requires devices:write.
{
"deviceId": "QP-SIM-00001",
"resourceType": "hive",
"resourceId": "hive_7",
"channelMapping": {
"environment.temperature": "brood_temp",
"mass.weight": "gross_weight"
}
}| Field | Required | Meaning |
|---|---|---|
deviceId | yes | Device public id. |
resourceType | yes | Your domain type. Free-form string chosen by your application, e.g. hive, room, pump, paddock. |
resourceId | yes | Your identifier for that object, e.g. hive_7, room_kitchen. |
channelMapping | no | Maps canonical capability keys to names meaningful in your domain. |
GET /v1/bindings — requires devices:read. Lists bindings. The list envelope is not
pinned; read it defensively (section 1.6).
6.2 Two examples
A beehive scale. One device on a hive, reporting weight, temperature and humidity:
curl -sS -X POST https://api.qiport.eu/v1/bindings \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"deviceId": "QP-SIM-00001",
"resourceType": "hive",
"resourceId": "hive_7",
"channelMapping": {
"mass.weight": "gross_weight",
"environment.temperature": "brood_temp",
"environment.humidity": "brood_humidity"
}
}'A room. Several devices bound to the same room — a Tuya temp/humidity sensor, a door
contact and a CO2 monitor. resourceType/resourceId are identical across all three; only
deviceId differs:
for DEV in QP-SIM-00002 QP-SIM-00003 QP-SIM-00004; do
curl -sS -X POST https://api.qiport.eu/v1/bindings \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$DEV\",\"resourceType\":\"room\",\"resourceId\":\"room_kitchen\"}"
doneThe relationship is many devices to one resource. Your application reads
GET /v1/bindings, groups by resourceType + resourceId, and renders one room card
fed by three devices. Nothing in the application needs to know that one of them is a
TS0201.
channelMapping is optional and purely a labelling convenience. If you omit it, use the
canonical capability keys directly — that is the more portable choice and the one to
prefer when you are unsure.
7. Control a device Live
Status: LIVE.
POST /v1/devices/{publicId}/commandsis deployed and actuates real hardware. Everything in this section describes what the running system does.Expect refusal to be the normal case, not the error case. A command is refused unless every condition holds at once, and the refusal arrives as HTTP 403 with a
reasons[]array meant to be shown to a human.
7.1 Request shape
POST /v1/devices/QP-SIM-00007/commands
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: hive7-valve-close-20260805T0611Z
{
"capabilityKey": "actuator.valve",
"value": false,
"mode": "desired",
"ttlSeconds": 60,
"confirm": true
}| Field | Provisional meaning |
|---|---|
capabilityKey | Canonical capability key. Must be writable in the catalogue. |
value | Typed exactly as the capability declares. A boolean capability takes true/false — 1, "1" and "on" are refused, deliberately, with no coercion anywhere. |
mode | desired (a setpoint that is reconciled — "the valve should be closed") or imperative (delivered once, never replayed — "pulse the door strike"). Replaying an imperative is how a gate opens twice. |
ttlSeconds | How long the command stays live. Clocked by the server. Out-of-range is rejected, not clamped. |
confirm | Required for S2 capabilities. |
idempotencyKey / Idempotency-Key header | Makes a retry safe. Reusing a key with a different capability or value is a conflict, not a silent success. |
Send an idempotency key on every command. A double-click without one produces two actuations.
7.2 The safety gate
A command is refused unless every one of these is affirmatively true:
- The capability exists in the catalogue and is
writable. - The device exists and belongs to your project.
- The device's lifecycle is not quarantined, revoked, retired or suspended.
- An unrevoked write grant exists for exactly that (device, capability) pair.
- The value matches the capability's declared type, range, enum and step.
- The class ceremony has been performed: S2 needs explicit confirmation; S3 needs an additional two-step challenge and a scope that is not issued today.
Absence of information is refusal. There is no path that reaches "allowed" by falling through. Consequences for your code:
- 403 is normal. Render the
reasons[]array. Do not retry, do not escalate to an error boundary, do not log it as a crash. - 403, not 404, for a device in another project. The commands path answers 403 for a
device it will not talk about, so it cannot be used to enumerate a fleet. Note this is
the opposite of
GET /v1/devices/:publicId, which answers 404. Do not write one shared "if 404 the device is gone" handler across both. - Write grants are administrative.
POST /v1/devices/{publicId}/capability-grantsis LIVE. Enabling write on an S2/S3 capability requires averifiedcatalogue profile; a issued today. Revoking a grant deliberately requires less privilege than granting one. Do not generate a UI flow that silently grants itself permission before acting.
7.5 Pairing — how a device gets into the fleet at all Live
Nothing joins by itself and nothing joins permanently by accident.
POST /v1/pairing/sessions open a window on one hub
GET /v1/pairing/sessions/{id} window + everything that joined it
POST /v1/pairing/sessions/{id}/adopt candidate -> device
POST /v1/pairing/sessions/{id}/reject refuse it, keep the record
DELETE /v1/pairing/sessions/{id} shut the window now
Four things a generated client must get right:
- A candidate is not a device. It has no
public_idand cannot be commanded, queried for telemetry, or bound to anything. Do not model it as a device with a missing field. - The window closes itself. You do not have to call DELETE. Do not build a retry loop that reopens a window because the first one "seems to have expired".
- One open window per hub. A second open returns 409 with the existing session id. That is a normal outcome when two operators press the button; show the existing window rather than erroring.
- Adoption is not permission. The response carries
write_blocked[]. Every entry is a capability created with an explicit denial row. Control requires granting each one throughPUT /v1/devices/{publicId}/capability-grants. A UI that hides this teaches operators that adopting an actuator means controlling it — exactly the belief the safety model exists to prevent.
Subscribe to GET /v1/live?types=pairing_update for push instead of polling. The
event carries sessionId, hubId, ieeeAddr and
state; deviceId is null until adoption, because until adoption there is
no device.
7.3 Feature-detect. Do not assume.
Write the call so that "not deployed yet" is an ordinary branch:
export type CommandOutcome =
| { kind: 'accepted'; commandId: string }
| { kind: 'unavailable'; feature: string; status: number }
| { kind: 'refused'; reasons: string[] };
export async function sendCommand(
apiFetch: (path: string, init: RequestInit) => Promise<Response>,
publicId: string,
capabilityKey: string,
value: unknown,
idempotencyKey: string,
): Promise<CommandOutcome> {
const res = await apiFetch(`/v1/devices/${encodeURIComponent(publicId)}/commands`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({ capabilityKey, value, mode: 'desired', confirm: true }),
});
// Not deployed yet: 404 (route absent) or 501 (explicitly unimplemented).
if (res.status === 404 || res.status === 501) {
return { kind: 'unavailable', feature: 'device.commands', status: res.status };
}
if (res.status === 403) {
const body = (await res.json().catch(() => ({}))) as { reasons?: unknown };
const reasons = Array.isArray(body.reasons) ? body.reasons.map(String) : ['forbidden'];
return { kind: 'refused', reasons };
}
if (!res.ok) throw new Error(`command failed: HTTP ${res.status}`);
const body = (await res.json()) as { command?: { commandId?: string } };
const commandId = body.command?.commandId;
if (typeof commandId !== 'string') throw new Error('command response missing commandId');
return { kind: 'accepted', commandId };
}Note the 404 ambiguity this creates: on the commands path a 404 means "route not deployed",
because a device that is missing or foreign answers 403. That is why the check above is
safe here and would be wrong on GET /v1/devices/:publicId.
A command being accepted is not the device having acted. 201 means the command was
validated, recorded and queued. Confirmation that the hardware did anything arrives later.
Never render "valve closed" on a 201 — render "closing…" and confirm from state.
8. Live data Live
Status: LIVE.
GET /v1/live(Server-Sent Events) is deployed. PollingGET /v1/devices/{publicId}/stateat 30–60 s remains a valid fallback for clients that cannot hold a stream open — stay inside the 30 requests/second rate limit if you do.Event types on the stream:
telemetry,device_status,command_update,pairing_update. Filter with?types=.
8.1 Why a fetch-based reader is required
The browser's native EventSource cannot set an Authorization header. There is no
option for it in the API. Since /v1/live requires a bearer token and putting a token in
the query string would write credentials into every proxy and access log, an application
must read the stream with fetch and parse SSE frames itself.
Two consequences you must not skip:
EventSourcenormally resends the last event id automatically as theLast-Event-IDheader on reconnect. A fetch-based reader must track the lastid:it saw and resend it itself — either as theLast-Event-IDheader or as alastEventIdquery parameter. Without it, every reconnect loses whatever arrived while disconnected.fetchdoes not reconnect. You own the retry loop and its backoff.
8.2 Provisional shape
| Item | Provisional value |
|---|---|
| Path | GET /v1/live |
| Scope | telemetry:live |
| Query | deviceIds, capabilities, types (all comma-separated), heartbeat (seconds, clamped 15–120), lastEventId |
| Response | text/event-stream |
| Frames | id: <opaque cursor>, event: telemetry, data: <json> |
| Heartbeat | A comment line : ping at the heartbeat interval. Not an event; ignore it, but use it to detect a dead socket. |
| Control events | event: replay_gap — you missed data; backfill from the telemetry REST endpoint. event: stream_error — the stream is ending. |
| Reconnect hint | retry: 5000 on open. |
| Concurrency | Limited concurrent streams per client; over the limit is 429 with Retry-After. |
The project scope always comes from the token. Query parameters can only ever narrow
what the token permits, never widen it — do not generate a projectId query parameter.
8.3 Reader
export type SseFrame = { id: string | null; event: string; data: string };
/** Reads an SSE stream with a bearer token. Yields frames; ignores comment lines. */
export async function* readSse(
url: string,
token: string,
lastEventId: string | null,
signal: AbortSignal,
): AsyncGenerator<SseFrame> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
};
if (lastEventId !== null) headers['Last-Event-ID'] = lastEventId;
const res = await fetch(url, { headers, signal });
if (res.status === 404 || res.status === 501) {
throw new Error('QiPort live stream unavailable on this deployment');
}
if (!res.ok || res.body === null) {
throw new Error(`live stream failed: HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) return;
buffer += decoder.decode(value, { stream: true });
// Frames are separated by a blank line. Handle both \n\n and \r\n\r\n.
for (;;) {
const sep = /\r?\n\r?\n/.exec(buffer);
if (sep === null) break;
const rawFrame = buffer.slice(0, sep.index);
buffer = buffer.slice(sep.index + sep[0].length);
let id: string | null = null;
let event = 'message';
const dataLines: string[] = [];
for (const line of rawFrame.split(/\r?\n/)) {
if (line.startsWith(':')) continue; // comment / heartbeat
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
const rest = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '');
if (field === 'id') id = rest;
else if (field === 'event') event = rest;
else if (field === 'data') dataLines.push(rest);
}
if (dataLines.length > 0) yield { id, event, data: dataLines.join('\n') };
}
}
}Wrap it in a reconnect loop that keeps the last id and backs off exponentially with jitter, exactly as in section 9. Behind nginx, an SSE stream that arrives in minutes-late lumps means response buffering is on upstream — that is a server configuration issue, not a client bug.
9. Error handling
9.1 Status code table
| Status | Body error | Meaning | Retry? | How |
|---|---|---|---|---|
| 200 / 201 | — | Success. 201 on create. | — | — |
| 400 | invalid_query, invalid_filter | Malformed query string. | No | Fix the request. Retrying is a loop. |
| 401 | unauthorized, unauthenticated | Missing, expired or invalid token. | Once | Discard cached token, re-authenticate, replay once. A second 401 is fatal. |
| 403 | forbidden | Either a missing scope, or a safety refusal carrying reasons[]. | No | With reasons[]: show them; this is a policy decision. Without: your token lacks the scope — fix the token request, not the call. |
| 404 | not_found | Resource absent or not visible to this token. | No | Do not distinguish "gone" from "not yours" — you cannot, deliberately. |
| 409 | conflict | Idempotency key reused with different content, or a state conflict. | No | A retry with the same body is safe only if the body is byte-identical. |
| 422 | invalid_request, invalid_value | The value or body failed validation — wrong type, out of range, off the step grid, missing. | No | Fix the value. No coercion will be applied server-side. |
| 429 | rate limited | 30 req/s per IP, burst 60 at the edge. Also concurrent-stream limits on /v1/live. | Yes | Honour Retry-After if present. Otherwise exponential backoff with jitter. |
| 500 | internal_error | Server fault. | Yes, bounded | Max ~3 attempts, exponential backoff. Do not hammer. |
| 502 / 503 / 504 | transport_unavailable etc. | Upstream or dependency unavailable. | Yes | Exponential backoff with jitter; honour Retry-After. |
| Network error / timeout | — | DNS, TLS, socket, abort. | Yes unless aborted | Backoff. If the request was aborted by your own AbortSignal, do not retry. |
Never retry a non-idempotent POST without an idempotency key. POST /v1/bindings and
POST /v1/devices/{publicId}/commands both fall in this category. With an idempotency key a retry
is safe; without one, a retry after a timeout can create a second binding or a second
actuation.
9.2 Backoff
Exponential with full jitter, capped:
export function backoffDelayMs(attempt: number, retryAfterHeader?: string | null): number {
// Server instruction wins when it is present and parseable (seconds, or an HTTP date).
if (retryAfterHeader !== undefined && retryAfterHeader !== null) {
const asSeconds = Number(retryAfterHeader);
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
return Math.min(asSeconds * 1000, 60_000);
}
const asDate = Date.parse(retryAfterHeader);
if (Number.isFinite(asDate)) {
return Math.min(Math.max(asDate - Date.now(), 0), 60_000);
}
}
const base = Math.min(30_000, 500 * 2 ** attempt); // 500, 1000, 2000, 4000 ... cap 30 s
return Math.random() * base; // full jitter: avoids a synchronised retry stampede
}Use full jitter, not a fixed delay. A fleet dashboard with 200 tiles that all retry at exactly 2000 ms reproduces the burst that caused the 429.
9.3 Error body shape
Errors are JSON. Two shapes occur:
{ "error": "not_found" }{ "error": "forbidden", "reasons": ["capability_not_writable", "device_not_addressable"] }reasons[] is a machine-readable list of refusal codes. Render it. Do not attempt to map
unknown reason codes to friendlier text you invented — show the code and a generic
sentence, so an operator can search for it.
Not every error body is guaranteed to be JSON (an edge proxy can emit HTML for a 502).
Parse defensively: await res.json().catch(() => ({})).
10. What does not exist yet
Complete list, so you do not invent any of it.
10.1 Building — written, not deployed
Nothing. Every endpoint in this guide is deployed and was exercised end to end before being written down. Commands, capability grants and the live stream moved from Building to Live; pairing shipped straight to Live.
10.2 Not built — roadmap only
| Feature | Status | What that means for your code |
|---|---|---|
| Pairing / Zigbee permit-join | No endpoint, no shape, no date. | You cannot make a device join. Devices must already exist in the registry. Do not generate a "Add device" flow that calls an API. |
| Provisioning and claiming | No endpoint. | No way to register new hardware programmatically. |
| OTA firmware updates | No endpoint. | Do not offer a firmware update button. |
| Webhooks | No endpoint, no subscription model, no signing scheme. | Do not generate a webhook receiver "for QiPort". Poll, or wait for SSE. |
| Admin UI | Does not exist. | Do not link to one or embed one. |
| QiPortKit SDK packages | Not published. | No @qiport/* npm package, no PyPI package. Write a plain HTTP client. |
10.3 Things that exist but are commonly assumed wrongly
| Assumption | Reality |
|---|---|
| "There is a WebSocket endpoint." | No. The live channel is SSE. |
| "A device that joined the radio is in the fleet." | No. It is a candidate until someone adopts it. |
| "An adopted actuator can be commanded." | No. Writable capabilities are blocked at adoption. |
| "The app can subscribe to MQTT for live data." | No. Applications never touch the broker. That is rule 1. |
"GET /v1/devices returns everything in one response." | No. It is cursor-paginated. |
| "A writable capability means this device is controllable." | No. A per-device grant must exist, and S2/S3 need a verified catalogue profile. |
| "404 means the device was deleted." | It means "not visible to this token". That includes another tenant's device. |
| "There is a refresh token." | No. client_credentials has none. Request a new access token. |
"DELETE /v1/bindings/:id exists." | Not documented here. Only POST /v1/bindings and GET /v1/bindings are LIVE. Do not generate other verbs against it. |
| "Capability keys can be derived from the model." | No. TS0601 covers dozens of unrelated products. Read the capability list. |
11. Checklist before you emit code
- [ ]No MQTT client library imported.
- [ ]No connection to the SLZB-06P10's address, port or web UI.
- [ ]No call to a provisioning, OTA or webhook endpoint. Pairing is live — see 7.5.
- [ ]No
@qiport/*package import. - [ ]Client secret read from the environment, never in client-side code, never in a query string.
- [ ]Token cached with expiry, refreshed 60 s early, one in-flight request, re-auth on 401 exactly once.
- [ ]Cursor pagination loop with a hard page bound.
- [ ]Application logic switches on capability keys, not
product_model. - [ ]
points[].capability_keyis read as snake_case;state[].capabilityas camelCase envelope. - [ ]Missing capability rendered as "no data", never as
0. - [ ]403 with
reasons[]handled as a normal outcome and displayed. - [ ]429 and 5xx retried with exponential backoff and jitter; 4xx not retried.
- [ ]Adoption flows treat
write_blockedas expected output, not as an error. - [ ]
AbortSignalplumbed through so requests can be cancelled.
Raw sources: llms.txt · ai-integration-guide.md · openapi.yaml · example-app/
API api.qiport.eu · Site qiport.eu · MQTT mqtt.qiport.eu:8883 — devices and Edge Agents only, never applications.