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

Three rules that must never be violated
01
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 HTTPS + OAuth2, and (when it ships) SSE. If your generated code imports an MQTT library, you have made a mistake.
02
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 connect to the coordinator's IP, its web UI or its TCP/serial port from an application, and do not suggest port-forwarding it.
03
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.
There is no pairing API — status: NOT BUILT

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.

FieldValue
API base URLhttps://api.qiport.eu
Docs base URLhttps://docs.qiport.eu
MQTT brokermqtt.qiport.eu:8883 — devices and Edge Agents only, never applications
AuthOAuth2 client_credentials, bearer token, expires_in 3600
Data residencyEU
Document date2026-08-05

Status vocabulary used throughout

StatusMeaningWhat you may generate
LiveImplemented, deployed, tested.Normal code.
BuildingCode 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 builtRoadmap. 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:

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

topology
Tuya Zigbee device --(Zigbee)--> SLZB-06P10 --(local LAN)--> QiPort Edge Agent
                                                                   |
                                                    outbound mTLS MQTT only
                                                                   v
                                                            QiPort Cloud (EU)
                                                                   |
                                                   HTTPS + OAuth2  v
                                                              Your application

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

ContextConventionExamples
Query parameters on GET /v1/devicessnake_casesite_id, status, model
Device object fieldsmixed; hub linkage is snake_caseparent_hub_id, manufacturer, product_model
State response envelopecamelCasedeviceId, connectivity, lastSeenAt
State entriescamelCasecapability, value, unit, quality, deviceTime, serverTime
Telemetry response envelopecamelCasedeviceId, resolution, aggregation, points
Telemetry point entriessnake_casecapability_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:

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

ItemValue
Method / pathPOST https://api.qiport.eu/v1/oauth/token
Content-Typeapplication/x-www-form-urlencoded
Body fieldsgrant_type=client_credentials, client_id, client_secret, scope
Token lifetimeexpires_in = 3600 seconds
Refresh tokenNone. client_credentials has no refresh token — request a new access token.
Failure401 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 401 invalid_client with correct credentials. That is the one-line switch noted in qiport-client.ts. Everything else in this section is pinned.

curl:

bash
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:

json
{
  "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

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:

  1. Store expiresAt = now + expires_in * 1000.
  2. 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.
  3. 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.
  4. On a 401 from a normal API call, discard the cached token, re-authenticate once, and replay the request. If the replay is also 401, fail. Never loop: a revoked client would otherwise produce an infinite retry storm against the token endpoint and then a 429.

All four behaviours are implemented in example-app/qiport-client.ts.

2.4 Where the secret lives

PlacementVerdict
Server-side environment variable / secret managerCorrect.
Backend-for-frontend that holds the secret and proxies to QiPortCorrect.
Browser JavaScript bundleNever. The bundle is public.
Mobile app binaryNever. Extractable by decompilation.
Committed .env file, source, or repositoryNever.
Query string of any requestNever. 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:

ScopeGrants
devices:readGET /v1/devices, GET /v1/devices/:publicId, .../state, GET /v1/sites, GET /v1/bindings
devices:writePOST /v1/bindings
telemetry:readGET /v1/devices/:publicId/telemetry
telemetry:liveGET /v1/live Building
catalog:readGET /v1/catalog/capabilities

Request the narrowest set your application actually needs.

High-risk scope. commands:high_risk gates 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 is commands:read, commands:write and commands: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 a scope string in code you ship now.

3. Discover devices Live

3.1 List

GET /v1/devices — requires devices:read.

ParameterTypeStatusMeaning
statusstringLiveFilter by device status.
modelstringLiveFilter by product model, e.g. TS0201.
site_idstringLiveFilter by site. Note the snake_case.
cursorstringLiveOpaque continuation token from the previous page.
limitintegerUnverifiedPage size. Conventional but not pinned in this document; the server may cap or ignore it. Pagination works via cursor regardless — prefer to omit it.
bash
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.

typescript
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

FieldValueMeaning
parent_hub_idnull or absentThis device is a hub / coordinator, or has no parent.
parent_hub_ida device public idThis 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.

typescript
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

FieldTypical Tuya valueNotes
manufacturer_TZ3000_xxxxxxxx, _TZE200_xxxxxxxx, _TZE204_xxxxxxxx, _TYZB01_xxxxxxxxReported by the device as the Zigbee manufacturerName. The _TZ/_TY prefix followed by digits and an underscore is the strong signal.
product_modelTS0201, TS0203, TS011F, TS0601Reported as the Zigbee modelID. TS0601 is the generic Tuya-datapoint model and covers dozens of physically different products.
manufacturerTuya, TuYa, _TZ... variantsSome 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:

typescript
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 ofDo
if (d.product_model === 'TS0201')if (caps.has('environment.temperature'))
if (d.manufacturer.startsWith('_TZ3000'))if (caps.has('binary.contact'))
A per-model parsing tableRead state[] and switch on capability
Hard-coded units per modelRead 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/capabilitiesLive, requires catalog:read. Fetch it once at startup and cache it; it changes on platform releases, not per request.

Capability keyUnitTypeWritableSafety class
environment.temperatureCnumbernoS0
environment.humidity%RHnumbernoS0
environment.co2ppmnumbernoS0
environment.pressurehPanumbernoS0
environment.illuminancelxnumbernoS0
environment.soil_moisture%numbernoS0
mass.weightkgnumbernoS0
motion.vibrationm/s2numbernoS0
water.leakbooleannoS0
water.flowL/minnumbernoS0
electrical.voltageVnumbernoS0
electrical.currentAnumbernoS0
electrical.power_activeWnumbernoS0
electrical.energy_importWhnumbernoS0
binary.contactbooleannoS0
binary.switchbooleanyesS1
actuator.relaybooleanyesS2
actuator.valvebooleanyesS2
actuator.contactorbooleanyesS3
system.battery_percent%numbernoS0
system.rssidBmnumbernoS0
system.lqinumbernoS0
system.uptimesnumbernoS0
system.device_temperatureCnumbernoS0
system.battery_voltageVnumbernoS0
energy.pv_powerWnumbernoS0
energy.load_powerWnumbernoS0
energy.grid_powerWnumbernoS0
energy.battery_powerWnumbernoS0
energy.produced_todaykWhnumbernoS0
energy.consumed_todaykWhnumbernoS0
evse.powerWnumbernoS0
evse.currentAnumbernoS0
evse.session_energykWhnumbernoS0
evse.total_energykWhnumbernoS0
evse.statetextnoS0
vehicle.battery_percent%numbernoS0
vehicle.rangekmnumbernoS0
hvac.setpoint_temperatureCnumbernoS0
hvac.modetextnoS0

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:

ClassMeaningControllable by default
S0Read-only measurement or diagnostic.n/a — never writable
S1Low-risk write, e.g. an indicator or a low-power switch.Yes, subject to scope
S2Relay or valve. Physical consequence.No. Grant required.
S3Mains 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.

bash
curl -sS https://api.qiport.eu/v1/devices/QP-SIM-00001/state \
  -H "Authorization: Bearer $TOKEN"
json
{
  "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"
    }
  ]
}
FieldNotes
deviceIdThe public id, same value you put in the path.
connectivityConnectivity of the device as the platform last observed it.
lastSeenAtISO-8601 UTC. Use it to grey out stale readings.
state[].capabilityA canonical key from section 4.4. Switch on this.
state[].valuenumber or boolean depending on the capability. Do not coerce.
state[].unitRender this; do not hard-code the unit.
state[].qualityArray 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[].deviceTimeWhen the device says it measured. May be skewed or missing on battery devices.
state[].serverTimeWhen QiPort stored it. Use this for ordering and for "is this stale".

Two rules for handling values:

5.2 History

GET /v1/devices/{publicId}/telemetry — requires telemetry:read.

ParameterTypeNotes
fromISO-8601 timestampStart of window, inclusive.
toISO-8601 timestampEnd of window.
capabilitiescomma-separated capability keysRestrict to what you will actually draw.
resolutionraw | 1m | 5m | 1h | 1dBucket width.
aggregatione.g. avg, min, max, sumHow samples inside a bucket are combined. Ignored for raw.

Aggregate response (pinned):

json
{
  "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.

WindowSensible resolutionPoints per capability (approx.)
Last 1 hour1m60
Last 6 hours1m360
Last 24 hours5m288
Last 7 days1h168
Last 30 days1h720
Last 90 days1d90
Last 1 year1d365
typescript
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:

bash
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 kindAggregationWhy
Temperature, humidity, pressure, CO2, illuminance, weightavgContinuous quantities.
electrical.power_active on a "peak load" chartmaxAn average hides the peak that trips a breaker.
electrical.energy_importmax per bucket, then difference between bucketsIt is a monotonically increasing meter total. Summing it multiplies your customer's energy bill by the number of samples.
water.leak, binary.contact, binary.switchmaxBooleans aggregate to "did it happen in this bucket".
system.battery_percentminYou 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.

json
{
  "deviceId": "QP-SIM-00001",
  "resourceType": "hive",
  "resourceId": "hive_7",
  "channelMapping": {
    "environment.temperature": "brood_temp",
    "mass.weight": "gross_weight"
  }
}
FieldRequiredMeaning
deviceIdyesDevice public id.
resourceTypeyesYour domain type. Free-form string chosen by your application, e.g. hive, room, pump, paddock.
resourceIdyesYour identifier for that object, e.g. hive_7, room_kitchen.
channelMappingnoMaps 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:

bash
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:

bash
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\"}"
done

The 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}/commands is 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

http
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
}
FieldProvisional meaning
capabilityKeyCanonical capability key. Must be writable in the catalogue.
valueTyped exactly as the capability declares. A boolean capability takes true/false1, "1" and "on" are refused, deliberately, with no coercion anywhere.
modedesired (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.
ttlSecondsHow long the command stays live. Clocked by the server. Out-of-range is rejected, not clamped.
confirmRequired for S2 capabilities.
idempotencyKey / Idempotency-Key headerMakes 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:

  1. The capability exists in the catalogue and is writable.
  2. The device exists and belongs to your project.
  3. The device's lifecycle is not quarantined, revoked, retired or suspended.
  4. An unrevoked write grant exists for exactly that (device, capability) pair.
  5. The value matches the capability's declared type, range, enum and step.
  6. 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:

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:

  1. A candidate is not a device. It has no public_id and cannot be commanded, queried for telemetry, or bound to anything. Do not model it as a device with a missing field.
  2. 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".
  3. 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.
  4. 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 through PUT /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:

typescript
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. Polling GET /v1/devices/{publicId}/state at 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:

  1. EventSource normally resends the last event id automatically as the Last-Event-ID header on reconnect. A fetch-based reader must track the last id: it saw and resend it itself — either as the Last-Event-ID header or as a lastEventId query parameter. Without it, every reconnect loses whatever arrived while disconnected.
  2. fetch does not reconnect. You own the retry loop and its backoff.

8.2 Provisional shape

ItemProvisional value
PathGET /v1/live
Scopetelemetry:live
QuerydeviceIds, capabilities, types (all comma-separated), heartbeat (seconds, clamped 15–120), lastEventId
Responsetext/event-stream
Framesid: <opaque cursor>, event: telemetry, data: <json>
HeartbeatA comment line : ping at the heartbeat interval. Not an event; ignore it, but use it to detect a dead socket.
Control eventsevent: replay_gap — you missed data; backfill from the telemetry REST endpoint. event: stream_error — the stream is ending.
Reconnect hintretry: 5000 on open.
ConcurrencyLimited 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

typescript
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

StatusBody errorMeaningRetry?How
200 / 201Success. 201 on create.
400invalid_query, invalid_filterMalformed query string.NoFix the request. Retrying is a loop.
401unauthorized, unauthenticatedMissing, expired or invalid token.OnceDiscard cached token, re-authenticate, replay once. A second 401 is fatal.
403forbiddenEither a missing scope, or a safety refusal carrying reasons[].NoWith reasons[]: show them; this is a policy decision. Without: your token lacks the scope — fix the token request, not the call.
404not_foundResource absent or not visible to this token.NoDo not distinguish "gone" from "not yours" — you cannot, deliberately.
409conflictIdempotency key reused with different content, or a state conflict.NoA retry with the same body is safe only if the body is byte-identical.
422invalid_request, invalid_valueThe value or body failed validation — wrong type, out of range, off the step grid, missing.NoFix the value. No coercion will be applied server-side.
429rate limited30 req/s per IP, burst 60 at the edge. Also concurrent-stream limits on /v1/live.YesHonour Retry-After if present. Otherwise exponential backoff with jitter.
500internal_errorServer fault.Yes, boundedMax ~3 attempts, exponential backoff. Do not hammer.
502 / 503 / 504transport_unavailable etc.Upstream or dependency unavailable.YesExponential backoff with jitter; honour Retry-After.
Network error / timeoutDNS, TLS, socket, abort.Yes unless abortedBackoff. 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:

typescript
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:

json
{ "error": "not_found" }
json
{ "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

FeatureStatusWhat that means for your code
Pairing / Zigbee permit-joinNo 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 claimingNo endpoint.No way to register new hardware programmatically.
OTA firmware updatesNo endpoint.Do not offer a firmware update button.
WebhooksNo endpoint, no subscription model, no signing scheme.Do not generate a webhook receiver "for QiPort". Poll, or wait for SSE.
Admin UIDoes not exist.Do not link to one or embed one.
QiPortKit SDK packagesNot published.No @qiport/* npm package, no PyPI package. Write a plain HTTP client.

10.3 Things that exist but are commonly assumed wrongly

AssumptionReality
"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

QiPort — EU-hosted IoT device cloud · document date 2026-08-05
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.