# QiPort API — OpenAPI 3.1 # # --------------------------------------------------------------------------- # EVERY ENDPOINT IN THIS FILE IS DEPLOYED # --------------------------------------------------------------------------- # Every operation below is live on https://api.qiport.eu and was exercised # end to end before being written down here. # # The convention that got us here is worth keeping: an endpoint whose code is # written but not deployed does NOT go in `paths`. Every mainstream generator # emits one callable method per operation, so a not-yet-deployed path becomes a # generated method that looks deployed and returns 404 — in a device-control API, # code that appears to close a valve and does not. Such endpoints live under the # top-level `x-qiport-building` extension instead, which generators ignore. # # That section is currently empty because nothing is waiting. Each operation # carries `x-qiport-status: live`. # # --------------------------------------------------------------------------- # Base URL https://api.qiport.eu # Auth OAuth2 client_credentials -> Bearer, 3600 s # Isolation every route is scoped by the token's project. There is no # parameter anywhere that names a project. # --------------------------------------------------------------------------- tags: - name: auth description: OAuth2 token issuance. - name: devices description: Device registry, state and history. - name: catalog description: Canonical capability catalogue. - name: sites description: Sites. - name: bindings description: Binding devices to application-domain objects. - name: pairing description: | Bringing a device into the fleet. A candidate is not a device: joining a radio proves proximity and nothing more. - name: ops description: Operational endpoints. # =========================================================================== # LIVE endpoints # =========================================================================== paths: /v1/oauth/token: post: tags: [auth] operationId: issueToken summary: Issue a bearer token (client_credentials) description: | OAuth2 client credentials grant (RFC 6749 §4.4). Tokens live 3600 seconds. There is no refresh token — request a new access token. Credentials are sent in the form body. If a deployment requires HTTP Basic instead, this endpoint answers 401 `invalid_client` for correct credentials. The client secret identifies a service. It must never be placed in browser JavaScript, a mobile binary, a repository, or a query string. security: [] # the token endpoint authenticates with the credentials themselves x-qiport-status: live requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object required: [grant_type, client_id, client_secret] properties: grant_type: type: string const: client_credentials client_id: type: string client_secret: type: string format: password scope: type: string description: > Space-separated. Only these are issued today: `devices:read` `devices:write` `telemetry:read` `telemetry:live` `catalog:read`. The scopes referenced by the BUILDING command code (`commands:read`, `commands:write`, `commands:high_risk`) are NOT issued and must not be requested. examples: - devices:read telemetry:read catalog:read responses: '200': description: Token issued. content: application/json: schema: $ref: '#/components/schemas/TokenResponse' example: access_token: eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHAtaGl2ZS1kYXNoIn0.SIG token_type: Bearer expires_in: 3600 scope: devices:read telemetry:read catalog:read '401': description: Bad client id or secret. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: invalid_client '429': $ref: '#/components/responses/RateLimited' /v1/devices: get: tags: [devices] operationId: listDevices summary: List devices description: | Cursor-paginated. The cursor is opaque — never parse or construct one. Loop until no cursor is returned, with a hard page bound in the client. Only devices already present in the registry are returned. There is no pairing API, so a device cannot be made to appear by calling anything here. x-qiport-status: live security: - oauth2ClientCredentials: [devices:read] parameters: - $ref: '#/components/parameters/FilterStatus' - $ref: '#/components/parameters/FilterModel' - $ref: '#/components/parameters/FilterSiteId' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: One page of devices. content: application/json: schema: $ref: '#/components/schemas/DeviceList' example: devices: - publicId: QP-HUB-00001 status: active manufacturer: SMLIGHT product_model: SLZB-06P10 parent_hub_id: null site_id: site_apiary_north - publicId: QP-SIM-00001 status: active manufacturer: _TZ3000_abcd1234 product_model: TS0201 parent_hub_id: QP-HUB-00001 site_id: site_apiary_north nextCursor: eyJvIjoyfQ '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' /v1/devices/{publicId}: parameters: - $ref: '#/components/parameters/PublicId' get: tags: [devices] operationId: getDevice summary: Get one device description: | A device belonging to another tenant returns **404**, not 403, so this endpoint cannot be used to enumerate other tenants' fleets. Treat 404 as "not visible to this token", covering both "does not exist" and "not yours". x-qiport-status: live security: - oauth2ClientCredentials: [devices:read] responses: '200': description: The device. content: application/json: schema: $ref: '#/components/schemas/Device' example: publicId: QP-SIM-00001 status: active manufacturer: _TZ3000_abcd1234 product_model: TS0201 parent_hub_id: QP-HUB-00001 site_id: site_apiary_north '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' /v1/devices/{publicId}/state: parameters: - $ref: '#/components/parameters/PublicId' get: tags: [devices] operationId: getDeviceState summary: Latest value per capability description: | Returns the most recent value for each capability the device has reported. A capability absent from `state[]` means "no value yet" — not zero and not false. Values are typed exactly as the capability declares; nothing is coerced. Order and staleness decisions should use `serverTime`, not `deviceTime`, because battery devices routinely report skewed or missing device clocks. x-qiport-status: live security: - oauth2ClientCredentials: [devices:read] responses: '200': description: Latest state. content: application/json: schema: $ref: '#/components/schemas/DeviceState' example: 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' - capability: environment.humidity value: 48.1 unit: '%RH' quality: [valid] deviceTime: '2026-08-05T06:11:33.001Z' serverTime: '2026-08-05T06:11:33.789Z' - capability: system.battery_percent value: 88 unit: '%' quality: [valid] deviceTime: '2026-08-05T06:11:33.001Z' serverTime: '2026-08-05T06:11:33.789Z' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' /v1/devices/{publicId}/telemetry: parameters: - $ref: '#/components/parameters/PublicId' get: tags: [devices] operationId: getDeviceTelemetry summary: Historical telemetry description: | Time-bucketed history. **Casing warning:** the envelope is camelCase (`deviceId`, `resolution`, `aggregation`, `points`) but each entry of `points[]` is snake_case (`capability_key`, `bucket`, `value`, `samples`). This is not a typo. `samples` is the number of underlying readings in the bucket. A bucket with `samples: 1` beside buckets with `samples: 60` is a gap, not a spike. The response shape documented here is the **aggregate** shape, produced by `resolution` values `1m`, `5m`, `1h` and `1d`. The shape returned for `resolution=raw` is not pinned in this document — inspect a live response before writing parsing logic for it. Choosing `aggregation`: `avg` for continuous quantities; `max` for peak power and for booleans; `min` for battery percentage; for `electrical.energy_import` take `max` per bucket and difference consecutive buckets — summing a monotonic meter total multiplies the customer's energy by the sample count. x-qiport-status: live security: - oauth2ClientCredentials: [telemetry:read] parameters: - name: from in: query required: true description: Start of the window, inclusive. ISO-8601. schema: { type: string, format: date-time } example: '2026-08-04T06:00:00Z' - name: to in: query required: true description: End of the window. ISO-8601. schema: { type: string, format: date-time } example: '2026-08-05T06:00:00Z' - name: capabilities in: query required: false description: Comma-separated canonical capability keys. Restrict this to what you will draw. schema: { type: string } example: environment.temperature,environment.humidity - name: resolution in: query required: false description: Bucket width. Choose from the window length and the chart's pixel width. schema: type: string enum: [raw, 1m, 5m, 1h, 1d] example: 5m - name: aggregation in: query required: false description: How samples inside a bucket are combined. Ignored for `resolution=raw`. schema: type: string examples: [avg, min, max, sum] example: avg responses: '200': description: Bucketed telemetry. content: application/json: schema: $ref: '#/components/schemas/TelemetryResponse' example: 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 - capability_key: environment.humidity bucket: '2026-08-04T18:00:00.000Z' value: 47.4 samples: 60 '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/RateLimited' /v1/catalog/capabilities: get: tags: [catalog] operationId: listCapabilities summary: Canonical capability catalogue description: | The vendor-neutral keys an application uses instead of Zigbee clusters, Tuya datapoints or model numbers. Fetch once at startup and cache — it changes on platform releases, not per request. Safety classes: `S0` read-only · `S1` low risk · `S2` relay/valve · `S3` mains contactor. **S2 and S3 are not controllable by default.** A per-device write grant row must exist, and a database trigger refuses to create one unless the device's catalogue profile is `verified`. x-qiport-status: live security: - oauth2ClientCredentials: [catalog:read] responses: '200': description: The catalogue. content: application/json: schema: $ref: '#/components/schemas/CapabilityList' example: capabilities: - key: environment.temperature unit: C value_type: number writable: false safety_class: S0 - key: environment.soil_moisture unit: '%' value_type: number writable: false safety_class: S0 - key: water.leak unit: null value_type: boolean writable: false safety_class: S0 - key: binary.switch unit: null value_type: boolean writable: true safety_class: S1 - key: actuator.valve unit: null value_type: boolean writable: true safety_class: S2 - key: actuator.contactor unit: null value_type: boolean writable: true safety_class: S3 '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' /v1/sites: get: tags: [sites] operationId: listSites summary: List sites description: Sites visible to this token. Site ids are used by the `site_id` device filter. x-qiport-status: live security: - oauth2ClientCredentials: [devices:read] parameters: - $ref: '#/components/parameters/Cursor' responses: '200': description: Sites. content: application/json: schema: $ref: '#/components/schemas/SiteList' example: sites: - id: site_apiary_north name: North apiary nextCursor: null '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' /v1/bindings: get: tags: [bindings] operationId: listBindings summary: List bindings description: | Bindings join a QiPort device to an object in the calling application's domain. Group by `resourceType` + `resourceId` to render one domain object fed by several devices. x-qiport-status: live security: - oauth2ClientCredentials: [devices:read] parameters: - $ref: '#/components/parameters/Cursor' responses: '200': description: Bindings. content: application/json: schema: $ref: '#/components/schemas/BindingList' example: bindings: - deviceId: QP-SIM-00001 resourceType: hive resourceId: hive_7 channelMapping: mass.weight: gross_weight environment.temperature: brood_temp - deviceId: QP-SIM-00002 resourceType: room resourceId: room_kitchen nextCursor: null '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' post: tags: [bindings] operationId: createBinding summary: Bind a device to an application-domain object description: | `resourceType` and `resourceId` are chosen by the calling application — QiPort does not interpret them. Many devices may bind to one resource. This operation is **not idempotent** and takes no idempotency key. Do not retry it blindly after a timeout; a retry can create a second binding. `channelMapping` is an optional labelling convenience. Omitting it and using the canonical capability keys directly is the more portable choice. x-qiport-status: live security: - oauth2ClientCredentials: [devices:write] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BindingRequest' examples: hive: summary: A beehive scale value: deviceId: QP-SIM-00001 resourceType: hive resourceId: hive_7 channelMapping: mass.weight: gross_weight environment.temperature: brood_temp environment.humidity: brood_humidity room: summary: A sensor in a room, no channel mapping value: deviceId: QP-SIM-00002 resourceType: room resourceId: room_kitchen responses: '201': description: Binding created. content: application/json: schema: $ref: '#/components/schemas/Binding' example: deviceId: QP-SIM-00001 resourceType: hive resourceId: hive_7 channelMapping: mass.weight: gross_weight '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/RateLimited' /health: get: tags: [ops] operationId: getHealth summary: Liveness description: Unauthenticated. Use for readiness probes, not for device data. x-qiport-status: live security: [] responses: '200': description: Service is up. content: application/json: schema: type: object additionalProperties: true properties: status: { type: string } example: status: ok '503': description: Service is not healthy. Do not treat as a device-level failure. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: unavailable # --------------------------------------------------------------------- # Pairing. The only way a device enters the fleet. # --------------------------------------------------------------------- /v1/pairing/sessions: post: tags: [pairing] operationId: openPairingSession summary: Open a bounded permit-join window on a hub x-qiport-status: live description: | **Status: LIVE.** Deployed and tested. Tells one coordinator to accept joins for `seconds`, then stop. The window closes itself: the coordinator counts down locally and the cloud sweeps expired windows every 10 s, so a permit window never depends on a second message arriving to shut. A hub may hold at most one open window, enforced by a unique index rather than by a check in this handler. Two overlapping windows on one coordinator make the attribution of a join ambiguous, and ambiguity is how a device ends up in somebody else's project. Devices that join do **not** become devices. They become candidates. See `POST /v1/pairing/sessions/{sessionId}/adopt`. requestBody: required: true content: application/json: schema: type: object required: [hub_id] properties: hub_id: type: string pattern: '^[A-Z0-9-]{6,48}$' example: QP-GW-0001 description: "The hub public_id. Must belong to the caller project." seconds: type: integer minimum: 10 maximum: 600 default: 60 description: | How long the radio accepts joins. 60 s is long enough to press a button on a sensor and short enough that a forgotten window shuts itself before anyone walks away. responses: '201': description: Window open. content: application/json: schema: type: object properties: session: $ref: '#/components/schemas/PairingSession' '404': description: | No hub with that id **in this project**. A hub belonging to another project is deliberately indistinguishable from one that does not exist. '409': description: This hub already has an open window. Close it first. get: tags: [pairing] operationId: listPairingSessions summary: Recent pairing windows for this project x-qiport-status: live parameters: - name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 20 } responses: '200': description: Sessions, newest first, with a candidate count each. /v1/pairing/sessions/{sessionId}: parameters: - name: sessionId in: path required: true schema: { type: string, format: uuid } get: tags: [pairing] operationId: getPairingSession summary: One window and everything that joined it x-qiport-status: live description: | **Status: LIVE.** Poll this, or subscribe to `GET /v1/live` and filter `types=pairing_update` for push. The live event carries no device id until a candidate is adopted, because until then there is no device. responses: '200': description: Session and candidates. content: application/json: schema: type: object properties: session: $ref: '#/components/schemas/PairingSession' candidates: type: array items: $ref: '#/components/schemas/PairingCandidate' '404': { description: No such session in this project. } delete: tags: [pairing] operationId: closePairingSession summary: Shut the window now x-qiport-status: live responses: '200': { description: Closed. } '404': { description: No open session with that id in this project. } /v1/pairing/sessions/{sessionId}/adopt: parameters: - name: sessionId in: path required: true schema: { type: string, format: uuid } post: tags: [pairing] operationId: adoptCandidate summary: Take a candidate into the fleet x-qiport-status: live description: | **Status: LIVE.** This is the moment a candidate becomes a device. Requires the candidate to have been interviewed — there is nothing to adopt it *as* until the fingerprint and the draft profile exist. Two things happen that callers must expect: 1. If the assessment quarantined the device, it is created with `lifecycle_status: quarantined` and cannot be commanded at all. 2. Every writable capability on the draft profile is written as an explicit **denial** row. Control requires granting each one deliberately via `PUT /v1/devices/{publicId}/capability-grants`. An adopted actuator is not a controllable actuator. Idempotent: adopting twice returns 200 with `idempotent: true` and does not create a second device. requestBody: required: true content: application/json: schema: type: object required: [ieee_addr] properties: ieee_addr: type: string pattern: '^0x[0-9a-f]{16}$' example: '0x00124b0022334455' responses: '201': description: Adopted. content: application/json: schema: type: object properties: adopted: { type: boolean } device_id: { type: string, example: QP-Z-00124B0022334455 } lifecycle_status: { type: string, enum: [active, quarantined] } write_blocked: type: array items: { type: string } description: Capabilities created with an explicit denial row. '200': { description: Already adopted. Idempotent replay. } '409': { description: Not interviewed yet. } /v1/pairing/sessions/{sessionId}/reject: parameters: - name: sessionId in: path required: true schema: { type: string, format: uuid } post: tags: [pairing] operationId: rejectCandidate summary: Refuse a candidate x-qiport-status: live description: | **Status: LIVE.** Marks the candidate rejected. It is kept, not deleted: a device that tried to join is evidence worth retaining even when the answer is no. requestBody: required: true content: application/json: schema: type: object required: [ieee_addr] properties: ieee_addr: { type: string, pattern: '^0x[0-9a-f]{16}$' } responses: '200': { description: Rejected. } '404': { description: No such candidate in this project. } /v1/devices/{publicId}/commands: post: tags: [devices] operationId: issueCommand summary: 'Write a value to a writable capability' x-qiport-status: live deprecated: false description: | **Status: LIVE.** Deployed and tested. This actuates real hardware. A command is refused unless every one of the following is affirmatively true: the capability exists and is `writable`; the device exists and belongs to the caller's project; its lifecycle is not quarantined/revoked/retired/suspended; an unrevoked write grant exists for exactly that (device, capability); the value matches the declared type, range, enum and step; and the class ceremony has been performed — S2 requires `confirm: true`, S3 requires a signed single-use challenge plus the `commands:high_risk` scope. Absence of information is refusal. **This path answers 403, not 404, for a missing or foreign device**, so it cannot be used to enumerate a fleet. That is the opposite of `GET /v1/devices/{publicId}`. Consequently, a 404 here means "route not deployed", which is what makes feature-detection on 404/501 safe on this path and wrong on the device read path. Nothing is coerced: `1`, `"1"` and `"on"` are refused for a boolean capability. A 201 means the command was validated, recorded and queued — **not** that the hardware acted. Render "closing…", then confirm from state. security: - oauth2ClientCredentials: [commands:write] parameters: - $ref: '#/components/parameters/PublicId' - name: Idempotency-Key in: header required: false description: > Makes a retry safe. Reusing a key with a different capability, value or mode is a 409, not a silent success. Send one on every command — a double click without one produces two actuations. schema: { type: string, minLength: 8, maxLength: 128 } requestBody: required: true content: application/json: schema: type: object required: [capabilityKey, value] properties: capabilityKey: type: string description: Canonical capability key. Must be writable in the catalogue. examples: [actuator.valve] value: description: > Typed exactly as the capability declares. Validated against type, range, enum and step. No coercion. mode: type: string enum: [desired, imperative] default: desired description: > `desired` is a reconciled setpoint ("the valve should be closed"). `imperative` is delivered once and never replayed ("pulse the door strike"). Replaying an imperative is how a gate opens twice. ttlSeconds: type: integer description: > How long the command stays live. Server-clocked. An out-of-range value is rejected, not clamped. confirm: type: boolean description: Required for S2 capabilities. idempotencyKey: type: string description: Body-level alternative to the `Idempotency-Key` header. example: capabilityKey: actuator.valve value: false mode: desired ttlSeconds: 60 confirm: true responses: '201': description: Command validated, recorded and queued. Not proof the device acted. content: application/json: schema: type: object properties: command: type: object properties: commandId: { type: string, format: uuid } status: { type: string } capabilityKey: { type: string } value: {} mode: { type: string } sequence: { type: [integer, 'null'] } nonce: { type: string } expiresAt: { type: string, format: date-time } createdAt: { type: string, format: date-time } deduplicated: { type: boolean } supersededCommandIds: type: array items: { type: string, format: uuid } example: command: commandId: 6f1d2a70-9a1e-4c3b-8a2f-1b7c9d0e5a44 status: queued capabilityKey: actuator.valve value: false mode: desired sequence: 41 nonce: hive7-valve-close-20260805T0611Z expiresAt: '2026-08-05T06:12:33.000Z' createdAt: '2026-08-05T06:11:33.000Z' deduplicated: false supersededCommandIds: [] '403': description: > Safety refusal or missing scope. Also returned for a missing or foreign device. Render `reasons[]`; do not retry. content: application/json: schema: $ref: '#/components/schemas/Error' examples: notAddressable: value: error: forbidden reasons: [device_not_addressable] noGrant: value: error: forbidden reasons: [write_grant_missing] confirmationRequired: value: error: confirmation_required reasons: [safety_class_s2_requires_confirm] '404': description: On this path, the route is not deployed. A missing device answers 403. '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/Unprocessable' '429': $ref: '#/components/responses/RateLimited' '503': description: Accepted by the API but not published to the transport. /v1/devices/{publicId}/capability-grants: post: tags: [devices] operationId: setCapabilityGrant summary: 'Enable or revoke a write grant' x-qiport-status: live deprecated: false description: | **Status: LIVE.** Deployed and tested. Administrative. Without an unrevoked `writeEnabled` grant for exactly one (device, capability) pair, no command for that capability can be issued. A database trigger refuses an enabling grant for S2/S3 unless the device's catalogue profile is `verified`; that refusal surfaces as 409 `grant_refused_by_policy`, not a 500. Granting write on a read-only capability is refused — it would create a row that looks like control and is not. Enabling an S3 grant requires a high-risk scope that is not issued today. Revoking deliberately requires less privilege than granting, so reducing blast radius is never blocked by a scope the on-call operator lacks. Do not generate a UI flow that silently grants itself permission before acting. security: - oauth2ClientCredentials: [devices:write] parameters: - $ref: '#/components/parameters/PublicId' requestBody: required: true content: application/json: schema: type: object required: [capabilityKey, writeEnabled, reason] properties: capabilityKey: { type: string } writeEnabled: { type: boolean } reason: type: string minLength: 8 maxLength: 500 description: > Mandatory audit record, at least 8 characters after trimming. A grant nobody can explain is a grant nobody revokes. example: capabilityKey: actuator.valve writeEnabled: true reason: Irrigation schedule for hive_7, approved ticket OPS-1841 responses: '200': description: Grant created, updated or revoked. content: application/json: schema: type: object properties: grant: type: object properties: capabilityKey: { type: string } writeEnabled: { type: boolean } grantedBy: { type: [string, 'null'] } reason: { type: [string, 'null'] } safetyClass: { type: [string, 'null'] } capabilityWritable: { type: [boolean, 'null'] } example: grant: capabilityKey: actuator.valve writeEnabled: true grantedBy: app-hive-dash reason: Irrigation schedule for hive_7, approved ticket OPS-1841 safetyClass: S2 capabilityWritable: true '403': $ref: '#/components/responses/Forbidden' '404': description: On this path, the route is not deployed. A missing device answers 403. '409': description: Policy refusal, e.g. the device's catalogue profile is not `verified`. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: conflict reasons: [grant_refused_by_policy] '422': $ref: '#/components/responses/Unprocessable' /v1/live: get: tags: [devices] operationId: streamLive summary: 'Server-Sent Events telemetry stream' x-qiport-status: live deprecated: false description: | **Status: LIVE.** Deployed and tested. Polling `GET /v1/devices/{publicId}/state` remains a valid fallback for clients that cannot hold a stream open. Applications read this instead of connecting to the MQTT broker. There is no WebSocket endpoint. The browser's native `EventSource` **cannot set an `Authorization` header**, so a client must use a fetch-based SSE reader. Two consequences: the client must track the last `id:` and resend it (as the `Last-Event-ID` header or the `lastEventId` query parameter) or it loses everything that arrived while disconnected; and `fetch` does not reconnect, so the client owns the retry loop and its backoff. Never put the bearer token in the query string — it lands in proxy and access logs. The project scope always comes from the token. Query parameters can only narrow what the token already permits; there is no `projectId` parameter. Frames: `id: `, `event: telemetry|replay_gap|stream_error`, `data: `. A `: ping` comment line is the heartbeat — ignore it as an event, but use it to detect a dead socket. `event: replay_gap` means data was missed; backfill from the telemetry endpoint. security: - oauth2ClientCredentials: [telemetry:live] parameters: - name: deviceIds in: query description: Comma-separated device public ids. Narrows only. schema: { type: string } - name: capabilities in: query description: Comma-separated canonical capability keys. Narrows only. schema: { type: string } - name: types in: query description: Comma-separated event types. Narrows only. schema: { type: string } - name: heartbeat in: query description: Heartbeat interval in seconds. Out-of-range values are clamped to 15–120, not rejected. schema: { type: integer, minimum: 15, maximum: 120, default: 30 } - name: lastEventId in: query description: Resume point, for clients that cannot set the `Last-Event-ID` header. schema: { type: string, maxLength: 256 } - name: Last-Event-ID in: header description: Opaque resume cursor from the last `id:` received. An unusable value is ignored, not fatal. schema: { type: string, maxLength: 256 } responses: '200': description: An event stream that does not end. content: text/event-stream: schema: { type: string } example: | retry: 5000 : open id: cTEuMTc3MDI2MjY5Mzc4OS40Mg event: telemetry data: {"deviceId":"QP-SIM-00001","capability":"environment.temperature","value":35.34,"unit":"C","serverTime":"2026-08-05T06:11:33.789Z"} : ping '400': description: Malformed filter or query. '401': $ref: '#/components/responses/Unauthorized' '404': description: Route not deployed. '429': # =========================================================================== components: securitySchemes: oauth2ClientCredentials: type: oauth2 description: | Bearer token from `POST /v1/oauth/token`. Lifetime 3600 s, no refresh token. The client secret is a service credential: server-side only. flows: clientCredentials: tokenUrl: https://api.qiport.eu/v1/oauth/token scopes: devices:read: Read devices, sites, bindings and device state. devices:write: Create bindings. telemetry:read: Read historical telemetry. telemetry:live: Subscribe to the live stream. catalog:read: Read the capability catalogue. # NOT listed because the token endpoint does not issue them today: # commands:read, commands:write, commands:high_risk parameters: PublicId: name: publicId in: path required: true description: > Stable device public id, e.g. `QP-SIM-00001`. Never the internal database UUID and never the Zigbee IEEE address. schema: type: string pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$' maxLength: 128 example: QP-SIM-00001 Cursor: name: cursor in: query required: false description: Opaque continuation token from the previous page. Never parse or construct one. schema: { type: string } Limit: name: limit in: query required: false description: > Page size. **Unverified** — conventional but not pinned by the platform; the server may cap or ignore it. Pagination works via `cursor` regardless, so prefer to omit it. x-qiport-verified: false schema: { type: integer, minimum: 1 } FilterStatus: name: status in: query required: false description: Filter by device status. schema: { type: string } example: active FilterModel: name: model in: query required: false description: > Filter by product model. Useful for inventory reports. Do not build application behaviour on it — `TS0601` alone covers dozens of physically different products. schema: { type: string } example: TS0201 FilterSiteId: name: site_id in: query required: false description: Filter by site. Note the snake_case name. schema: { type: string } example: site_apiary_north responses: Unauthorized: description: Missing, expired or invalid token. Re-authenticate once and replay; never loop. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: unauthorized } Forbidden: description: > Missing OAuth scope, or a safety refusal carrying `reasons[]`. Not retryable. This is a normal outcome for actuators and must be rendered, not treated as a crash. content: application/json: schema: { $ref: '#/components/schemas/Error' } examples: missingScope: value: { error: forbidden } safetyRefusal: value: { error: forbidden, reasons: [write_grant_missing, capability_not_writable] } NotFound: description: > Absent, or not visible to this token — deliberately indistinguishable, so the API cannot be used to enumerate other tenants' fleets. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: not_found } Conflict: description: State conflict, or an idempotency key reused with different content. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: conflict, reasons: [idempotency_key_reused_for_different_command] } Unprocessable: description: > The body or a value failed validation — wrong type, out of range, off the step grid, or missing. Nothing is coerced server-side. Not retryable. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: invalid_value, reasons: [value_type_mismatch] } RateLimited: description: > Rate limited. The edge allows 30 requests/second per IP with burst 60. Retry with exponential backoff and full jitter; honour `Retry-After` when present. headers: Retry-After: description: Seconds, or an HTTP date. schema: { type: string } content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { error: rate_limited } schemas: PairingSession: type: object properties: id: { type: string, format: uuid } hub_public_id: { type: string, example: QP-GW-0001 } status: { type: string, enum: [open, closed, expired] } permit_seconds: { type: integer } opened_by: { type: string, nullable: true } opened_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } closed_at: { type: string, format: date-time, nullable: true } close_reason: { type: string, nullable: true } PairingCandidate: description: | Something that joined the radio. Not a device, and not addressable as one: it has no `public_id` until it is adopted. type: object properties: ieee_addr: { type: string, example: '0x00124b0022334455' } state: type: string enum: [joined, interviewed, adopted, rejected] fingerprint_hash: { type: string, nullable: true } variant_hash: { type: string, nullable: true } match_kind: type: string nullable: true enum: [exact, variant, none] description: | `none` means no catalogue profile matched and the proposal below is a draft assembled from standard ZCL clusters. proposed_profile: type: object nullable: true description: | Draft profile. `overallConfidence` is the MINIMUM of the individual confidences, not the mean: a profile is consumed whole, so one coin-flip mapping among six certain ones makes the profile a coin flip. quarantined: type: boolean description: Defaults to true. Only an assessment can lower it, never its absence. quarantine_reasons: type: array items: { type: string } write_blocked: type: array items: { type: string } description: Capabilities that will be created with an explicit denial on adoption. device_id: { type: string, format: uuid, nullable: true } first_seen: { type: string, format: date-time } last_seen: { type: string, format: date-time } Error: type: object description: | Error bodies are JSON, but an edge proxy can emit HTML for a 502 — parse defensively. `reasons[]` is a machine-readable list of refusal codes; render the codes rather than mapping unknown ones to invented friendly text. properties: error: type: string description: Stable machine-readable error code. reasons: type: array description: Present on safety refusals and validation failures. items: { type: string } detail: type: string required: [error] TokenResponse: type: object required: [access_token, token_type, expires_in] properties: access_token: { type: string } token_type: { type: string, const: Bearer } expires_in: type: integer description: Seconds. 3600. There is no refresh token — request a new access token. scope: { type: string } Device: type: object description: | A device in the registry. `parent_hub_id` and `product_model` are snake_case. Application behaviour should key on capabilities, not on `manufacturer` or `product_model` — those are for display, diagnostics and inventory. additionalProperties: true required: [publicId] properties: publicId: type: string description: Stable public id. Never the internal UUID, never the IEEE address. status: { type: [string, 'null'] } manufacturer: type: [string, 'null'] description: > Zigbee `manufacturerName` as reported. Tuya hardware typically reports `_TZ3000_*`, `_TZE200_*`, `_TZE204_*` or `_TYZB01_*`. Case is significant — do not case-fold before comparing. product_model: type: [string, 'null'] description: > Zigbee `modelID` as reported, e.g. `TS0201`, `TS0203`, `TS011F`, `TS0601`. `TS0601` is Tuya's generic datapoint model and says nothing about function. parent_hub_id: type: [string, 'null'] description: > Public id of the hub this device is reached through. `null` or absent means this device is itself a hub / coordinator, e.g. the SLZB-06P10. site_id: { type: [string, 'null'] } DeviceList: type: object description: > Cursor-paginated device page. Clients should read the list and cursor tolerantly — see the guide's "Response envelopes that are not pinned" section. x-qiport-verified: false additionalProperties: true properties: devices: type: array items: { $ref: '#/components/schemas/Device' } nextCursor: type: [string, 'null'] description: Opaque. Absent or null means this was the last page. StateEntry: type: object required: [capability, value] properties: capability: type: string description: Canonical capability key. Switch on this, not on the model string. value: type: [number, boolean, string, 'null'] description: Typed as the capability declares. Do not coerce; `1` is not `true`. unit: type: [string, 'null'] description: Render this rather than a hard-coded constant. quality: type: array items: { type: string } description: > Markers such as `["valid"]`. Anything other than `["valid"]` should be surfaced rather than silently charted. deviceTime: type: [string, 'null'] format: date-time description: Device's own clock. May be skewed or missing on battery devices. serverTime: type: [string, 'null'] format: date-time description: When QiPort stored it. Use this for ordering and staleness. DeviceState: type: object description: > Latest value per capability. A capability absent from `state[]` means "no value yet" — not zero, not false. required: [deviceId, state] properties: deviceId: { type: string } connectivity: { type: [string, 'null'] } lastSeenAt: { type: [string, 'null'], format: date-time } state: type: array items: { $ref: '#/components/schemas/StateEntry' } TelemetryPoint: type: object description: > snake_case keys inside a camelCase envelope. Write `p.capability_key`, not `p.capabilityKey`. required: [capability_key, bucket, value] properties: capability_key: { type: string } bucket: type: string format: date-time description: Start of the time bucket. value: { type: [number, boolean, 'null'] } samples: type: [integer, 'null'] description: > Underlying readings in this bucket. A bucket with `samples: 1` beside buckets with `samples: 60` is a gap, not a spike. TelemetryResponse: type: object description: Aggregate shape, returned for `resolution` in `1m|5m|1h|1d`. required: [deviceId, points] properties: deviceId: { type: string } resolution: { type: string } aggregation: { type: [string, 'null'] } points: type: array items: { $ref: '#/components/schemas/TelemetryPoint' } Capability: type: object additionalProperties: true required: [key] properties: key: type: string description: Canonical capability key, e.g. `environment.temperature`. unit: { type: [string, 'null'] } value_type: { type: [string, 'null'] } writable: type: boolean description: > Writable in the catalogue. This is necessary, never sufficient — a per-device write grant must also exist. safety_class: type: [string, 'null'] enum: [S0, S1, S2, S3, null] description: > `S0` read-only · `S1` low risk · `S2` relay/valve · `S3` mains contactor. S2 and S3 are not controllable by default. CapabilityList: type: object x-qiport-verified: false additionalProperties: true properties: capabilities: type: array items: { $ref: '#/components/schemas/Capability' } SiteList: type: object x-qiport-verified: false additionalProperties: true properties: sites: type: array items: type: object additionalProperties: true nextCursor: { type: [string, 'null'] } BindingRequest: type: object required: [deviceId, resourceType, resourceId] properties: deviceId: type: string description: Device public id. resourceType: type: string description: Application-domain type, chosen by the caller, e.g. `hive`, `room`, `pump`. resourceId: type: string description: Caller's identifier for that object, e.g. `hive_7`. channelMapping: type: object additionalProperties: { type: string } description: > Optional. Maps canonical capability keys to names meaningful in the caller's domain. Omitting it and using the canonical keys directly is more portable. Binding: allOf: - $ref: '#/components/schemas/BindingRequest' - type: object additionalProperties: true BindingList: type: object x-qiport-verified: false additionalProperties: true properties: bindings: type: array items: { $ref: '#/components/schemas/Binding' } nextCursor: { type: [string, 'null'] }