{"openapi": "3.1.0", "info": {"title": "Sensor Network client API", "version": "0.3.0", "description": "Local development API. Bearer credentials are tenant scoped. Read keys cannot ingest. New keys expire after 1\u2013365 days (default 90), with immediate revocation. Default limit 600 authenticated requests/key/minute in this process; 429 includes Retry-After. Production HTTPS and OAuth are separate release work."}, "servers": [{"url": "/", "description": "Same host and configured portal port"}], "components": {"securitySchemes": {"ApiKey": {"type": "http", "scheme": "bearer"}}, "schemas": {"Error": {"type": "object", "properties": {"error": {"type": "string"}}, "required": ["error"]}, "Measurement": {"type": "object", "properties": {"name": {"type": "string"}, "unit": {"type": "string"}, "value": {"type": "number"}, "channel_id": {"type": "string"}}, "required": ["name", "unit", "value"]}, "Event": {"type": "object", "properties": {"event_id": {"type": "string"}, "site_id": {"type": "string"}, "gateway_id": {"type": "string"}, "sensor_id": {"type": "string"}, "tenant_id": {"type": "string"}, "observed_at": {"type": "string", "format": "date-time"}, "received_at": {"type": "string", "format": "date-time"}, "measurements": {"type": "array", "items": {"$ref": "#/components/schemas/Measurement"}}, "quality": {"type": "string", "enum": ["valid", "invalid", "estimated", "clock_error", "stale"]}, "source": {"type": "string"}}, "required": ["event_id", "site_id", "gateway_id", "sensor_id", "observed_at", "measurements"]}, "History": {"type": "object", "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/Event"}}, "limit": {"type": "integer"}, "offset": {"type": "integer"}, "has_more": {"type": "boolean"}, "next_cursor": {"type": ["string", "null"]}, "time_bounds": {"type": "string"}}, "required": ["items", "has_more", "next_cursor"]}, "Channel": {"type": "object", "properties": {"id": {"type": "string"}, "tenant_id": {"type": "string"}, "sensor_id": {"type": "string"}, "metric": {"type": "string"}, "unit": {"type": "string"}, "value_kind": {"type": "string"}, "report_interval_seconds": {"type": ["integer", "null"]}, "asset_reference": {"type": ["string", "null"]}, "enabled": {"type": "integer", "enum": [0, 1]}}, "required": ["id", "tenant_id", "sensor_id", "metric", "unit"]}, "ChannelReading": {"type": "object", "properties": {"tenant_id": {"type": "string"}, "device_id": {"type": "string"}, "channel_id": {"type": "string"}, "unit": {"type": "string"}, "quality": {"type": "string"}, "reading_id": {"type": ["string", "null"]}, "measured_at": {"type": ["string", "null"]}, "received_at": {"type": ["string", "null"]}, "value": {"type": ["number", "null"]}, "freshness_seconds": {"type": "integer"}, "reported_quality": {"type": "string"}}, "required": ["tenant_id", "device_id", "channel_id", "value", "unit", "quality", "measured_at", "received_at"]}, "Aggregate": {"type": "object", "properties": {"from": {"type": "string"}, "to": {"type": "string"}, "quality": {"type": "string"}, "minimum": {"type": ["number", "null"]}, "maximum": {"type": ["number", "null"]}, "average": {"type": ["number", "null"]}, "coverage": {"type": ["number", "null"]}, "count": {"type": "integer"}, "excluded_count": {"type": "integer"}, "expected_count": {"type": ["integer", "null"]}, "observed_span_seconds": {"type": "number"}}, "required": ["from", "to", "minimum", "maximum", "average", "count", "coverage", "quality"]}, "Alarm": {"type": "object", "properties": {"id": {"type": "string"}, "tenant_id": {"type": "string"}, "rule_id": {"type": "string"}, "sensor_id": {"type": "string"}, "state": {"type": "string"}, "raised_at": {"type": "string"}, "unit": {"type": "string"}, "trigger_event_id": {"type": "string"}, "cleared_at": {"type": ["string", "null"]}, "acknowledged_at": {"type": ["string", "null"]}, "acknowledged_by": {"type": ["string", "null"]}, "rule_version": {"type": "integer"}, "sequence": {"type": "integer"}, "latest_value": {"type": "number"}}, "required": []}, "AlarmEvent": {"type": "object", "properties": {"id": {"type": "string"}, "tenant_id": {"type": "string"}, "alarm_id": {"type": "string"}, "kind": {"type": "string"}, "occurred_at": {"type": "string"}, "received_at": {"type": "string"}, "sequence": {"type": "integer"}, "watermark": {"type": "integer"}, "payload": {"type": "object"}}, "required": []}, "IngestEvent": {"type": "object", "properties": {"event_id": {"type": "string"}, "site_id": {"type": "string"}, "gateway_id": {"type": "string"}, "sensor_id": {"type": "string"}, "tenant_id": {"type": "string"}, "observed_at": {"type": "string", "format": "date-time"}, "received_at": {"type": "string", "format": "date-time"}, "measurements": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"$ref": "#/components/schemas/Measurement"}}, "quality": {"type": "string", "enum": ["valid", "invalid", "estimated", "clock_error"]}, "source": {"type": "string"}}, "required": ["event_id", "site_id", "gateway_id", "sensor_id", "observed_at", "measurements"]}}}, "paths": {"/api/v1/inventory": {"get": {"summary": "Read tenant inventory", "description": "Sites include an IANA timezone for their local calendar; measurement timestamps remain UTC. Names may change; IDs remain stable. Inventory is currently an unpaginated development collection.", "security": [{"ApiKey": []}], "parameters": [], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"sites": {"type": "array", "items": {"type": "object"}}, "gateways": {"type": "array", "items": {"type": "object"}}, "sensors": {"type": "array", "items": {"type": "object"}}, "tenant_id": {"type": "string"}, "channels": {"type": "array", "items": {"$ref": "#/components/schemas/Channel"}}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/topology": {"get": {"summary": "Read one site ownership tree", "description": "One site contains multiple gateways, each with its own devices and measurement channels. Connections are logical ownership, not measured radio routing.", "security": [{"ApiKey": []}], "parameters": [{"name": "site_id", "in": "query", "required": true, "description": "", "schema": {"type": "string"}}], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"site": {"type": "object"}, "gateways": {"type": "array", "items": {"type": "object"}}, "relationship": {"type": "string"}, "radio_routes": {"type": "string"}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/diagnostics": {"get": {"summary": "Read per-device measurement diagnostics", "description": "Each metric retains its own observation/receipt times, quality and timestamp origin. An updated power report does not refresh an older battery value. LQI and RSSI are separate units; missing diagnostics stay unknown.", "security": [{"ApiKey": []}], "parameters": [], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object"}}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/channels": {"get": {"summary": "Read measurement channels", "description": "A channel ID is stable for the tenant, sensor, metric and original unit. A unit change creates a separate channel. Reporting interval remains null until an operator configures it. Value meaning is unspecified until configured.", "security": [{"ApiKey": []}], "parameters": [], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/Channel"}}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/readings": {"get": {"summary": "Read raw event history", "description": "Cursor pages retain the original row snapshot, including equal timestamp ties. Uploads after the first page are excluded from that traversal. Raw event history currently has no maximum date range, bounded to 500 events/request. Annotations add channel IDs without overwriting stored raw measurements.", "security": [{"ApiKey": []}], "parameters": [{"name": "site_id", "in": "query", "required": false, "description": "", "schema": {"type": "string"}}, {"name": "gateway_id", "in": "query", "required": false, "description": "", "schema": {"type": "string"}}, {"name": "sensor_id", "in": "query", "required": false, "description": "", "schema": {"type": "string"}}, {"name": "from", "in": "query", "required": false, "description": "Inclusive UTC observation bound with explicit timezone", "schema": {"type": "string"}}, {"name": "to", "in": "query", "required": false, "description": "Exclusive UTC observation bound with explicit timezone", "schema": {"type": "string"}}, {"name": "limit", "in": "query", "required": false, "description": "Keep this value unchanged when following a cursor", "schema": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}}, {"name": "offset", "in": "query", "required": false, "description": "Legacy offset pagination; omit with cursor", "schema": {"type": "integer", "minimum": 0}}, {"name": "cursor", "in": "query", "required": false, "description": "Signed snapshot cursor; expires after 24 hours. Repeat the same filters and limit.", "schema": {"type": "string"}}], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/History"}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/readings/latest": {"get": {"summary": "Read the latest event per sensor", "description": "Latest event may omit a metric that reported separately; use channel batch latest for a measurement view. Quality is computed from reporting quality, receipt clock checks and configured freshness.", "security": [{"ApiKey": []}], "parameters": [], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/Event"}}, "missing_sensor_ids": {"type": "array", "items": {"type": "string"}}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/channels/latest": {"get": {"summary": "Batch latest measurement readings", "description": "Missing values are null. observed_at later than server receipt by more than 60 seconds stays clock_error. Stale values remain visible with stale quality.", "security": [{"ApiKey": []}], "parameters": [{"name": "channel_ids", "in": "query", "required": true, "description": "Comma-separated IDs; 1\u2013100. Any unavailable ID returns 404 for the whole request.", "schema": {"type": "string"}}], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/ChannelReading"}}, "maximum_batch_size": {"type": "integer"}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/channels/aggregates": {"get": {"summary": "Summarize raw values without interpolation", "description": "Maximum 31 days and 250,000 raw events per query. Min/max/mean/count include only valid observations. Empty intervals return null values and missing quality. Coverage is occupied expected reporting slots / expected slots; unknown interval gives null coverage. Partial edge buckets use their actual duration. Historical freshness is not treated as invalid quality. Cumulative-meter averages are not interval consumption; energy reset/rollover handling is not implemented.", "security": [{"ApiKey": []}], "parameters": [{"name": "channel_id", "in": "query", "required": true, "description": "", "schema": {"type": "string"}}, {"name": "from", "in": "query", "required": true, "description": "Inclusive timestamp; explicit timezone", "schema": {"type": "string"}}, {"name": "to", "in": "query", "required": true, "description": "Exclusive timestamp; explicit timezone", "schema": {"type": "string"}}, {"name": "interval_seconds", "in": "query", "required": false, "description": "UTC epoch-aligned bucket length", "schema": {"type": "integer", "minimum": 60, "maximum": 86400, "default": 900}}], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"channel_id": {"type": "string"}, "unit": {"type": "string"}, "interval_seconds": {"type": "integer"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/Aggregate"}}, "coverage_method": {"type": "string"}, "interpolation": {"type": "string"}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/alarms": {"get": {"summary": "Reconcile measurement conditions and recent events", "description": "Up to 500 recent conditions and 100 transitions. Events in this compatibility response contain payload as serialized JSON. Acknowledgement is separate from sensor state; raised/cleared transitions are durable. Current rules are version 1; scheduling and offline alarms are not implemented.", "security": [{"ApiKey": []}], "parameters": [], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"rules": {"type": "array", "items": {"type": "object"}}, "alarms": {"type": "array", "items": {"$ref": "#/components/schemas/Alarm"}}, "events": {"type": "array", "items": {"type": "object"}}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/alarms/{id}": {"get": {"summary": "Retrieve a condition by ID", "description": "", "security": [{"ApiKey": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Alarm"}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/alarm-events": {"get": {"summary": "Read and poll the durable change feed", "description": "Next cursor is always returned, including empty pages; poll it for new transitions. Cursor expires after 30 days. Current development events are not pruned; production retention guarantees are not established. Each event ID is unique, and sequence increases within an alarm. Webhook delivery/replay is not yet implemented.", "security": [{"ApiKey": []}], "parameters": [{"name": "cursor", "in": "query", "required": false, "description": "Tenant-bound cursor; initially omit to start at the first event", "schema": {"type": "string"}}, {"name": "limit", "in": "query", "required": false, "description": "integer", "schema": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}}], "responses": {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/AlarmEvent"}}, "next_cursor": {"type": "string"}, "has_more": {"type": "boolean"}, "cursor_lifetime_days": {"type": "integer"}}, "required": []}}}}, "400": {"description": "Invalid filter or cursor", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Invalid, expired or revoked credential", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "Wrong scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Resource unavailable in this tenant", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limit; Retry-After in seconds", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/ingest": {"post": {"summary": "Durably ingest a gateway event", "description": "Gateway-bound ingest credential only. Credential sets tenant ownership; device/site/gateway mapping is checked. Matching duplicate IDs acknowledge safely; conflicting payloads return 409. Valid/invalid/estimated/clock_error are accepted reported qualities; stale is computed on read. Preserve original units. AI-derived processing is not enabled.", "security": [{"ApiKey": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IngestEvent"}}}}, "responses": {"200": {"description": "Committed; acknowledged_event_id returned"}, "400": {"description": "Invalid event"}, "401": {"description": "Invalid credential"}, "403": {"description": "Wrong device/gateway/scope"}, "409": {"description": "Conflicting event ID"}, "429": {"description": "Rate limit; retry after Retry-After"}}}}, "/api/v1/gateway-status": {"post": {"summary": "Report gateway bridge health", "security": [{"ApiKey": []}], "description": "Gateway-bound ingest key only. Receipt time is recorded by the server; a fresh heartbeat describes the bridge/source connection, not every sensor sample.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"gateway_id": {"type": "string"}, "source_connected": {"type": "boolean"}, "pending_events": {"type": "integer", "minimum": 0}, "last_source_contact": {"type": ["string", "null"]}, "transport": {"type": "string"}}, "required": ["gateway_id", "source_connected", "pending_events", "transport"]}}}}, "responses": {"200": {"description": "Status stored"}, "400": {"description": "Invalid status"}, "403": {"description": "Wrong gateway or scope"}}}}}}