Skip to main content

IoT platform

Ovok's IoT surface turns device telemetry into FHIR resources + Signals events through per-project rule-chains. This page is the operator's map — model, endpoints, console UX, deployment.

Every backend endpoint below is admin-only (RoleAdminGuard); project scoping is automatic via the caller's bearer token — clients cannot spoof meta.project. Console pages soft-degrade with a "Waiting on ovok-core deploy" card when an endpoint isn't live on the current deployment, so you always know whether a UI stub is a bug vs a deployment lag.

Overview

Three moving parts, mapped to console URLs:

ConceptConsole URLovok-core surface
Devices — FHIR Device per serial number/devices + /devices/[id]/v1/iot-device/{provision,devices,transport-config,observability}
Rule chains — visual DAG authored per project/iot-builder + /iot-builder/[id]/v1/iot-device/rule-chain/*
Signals — landed observations + per-project routing/signals + /settings/iot/signals/v1/signals/{observations,project-config}
Safety — kill-flag + killswitch + capability grants/settings/general + /settings/iot/{killswitch,capabilities}project-settings + /v1/iot-device/{killswitch,capabilities}

The chain workspace (/iot-builder/[id]) is a full-page palette + DAG canvas + schema-driven inspector — no tabs, no raw-JSON detour.

Device model

A device shows up in Ovok as a FHIR Device resource keyed by serial number via Device.identifier[system=OvokDeviceIdentifierSystem]. Both the admin /provision endpoint and the ingest hot-path materialize devices atomically via Medplum's createResourceIfNoneExist (same pattern as sleepiz's DeviceInitializationRunner) — repeated calls with the same S/N converge to the same Device/<S/N> under any race, so "auto-create on first ingest" is safe by construction.

Device/<internalId> ← Medplum-generated UUID
├── identifier[]
│ └── { system: OvokDeviceIdentifierSystem,
│ value: <serialNumber> } ← the S/N is the queryable key
├── serialNumber ← native FHIR field
├── deviceName[0].name ← friendly display (defaults to S/N)
├── meta.project ← stamped by Medplum from the caller's token
├── patient? ← optional link
└── identifier[system=IOT_TOKEN_HASH_SYSTEM]?
← sha256(secret) once a token is minted

The token wire format is iotk_<deviceId-b64url>_<32-byte-secret-b64url>. Only the sha256 hash is stored; the raw token is returned exactly ONCE on mint / rotate through the console's once-visible reveal panel (never persisted to browser storage — the primitive unmounts on route change).

Provisioning flow

Three legitimate paths a device can arrive on:

  1. Admin (recommended)POST /v1/iot-device/provision with { serialNumber, deviceName?, patientRef? }. Idempotent. Console entry point: /devices+ New device.
  2. First ingest — a device that already has a token bound to its serial number can send telemetry and the ingest core materializes the FHIR Device on demand via the same createResourceIfNoneExist path — no separate call required.
  3. Raw FHIR — power users can PUT a Device resource directly. Provided the identifier system + value match the S/N convention, the token/ingest flow works against it.

Once the Device exists, mint a token via POST /v1/iot-device/devices/:id/token (console: token panel on /devices/[id]). Response returns { token: "iotk_..." } exactly once.

Configure the device firmware using the recipe from GET /v1/iot-device/transport-config — never hard-code environment URLs into firmware; the console's transport picker copies snippets directly from that endpoint.

Ingestion

Three transports flow through the same IotIngestCoreService.ingest core (verify → normalize → admit → enqueue) so the executor stays transport-blind. Discovery config lives at GET /v1/iot-device/transport-config (never returns secrets) — HTTP URL, MQTT broker + topic + user-property + QoS, WS URL + namespace + event name — every field nullable when its env var is unset.

HTTP

POST https://api.<env>.ovok.com/v1/iot-device/telemetry
x-iot-device-token: iotk_...
content-type: application/json

{ "payload": {...}, "reportedAt": "<ISO> | null", "schemaVersion": "<str> | null" }
  • Success202 Accepted with { "jobId": "<messageId>" }.
  • 401 — bad token OR IOT_ENABLED=false (deliberately indistinguishable). Console simulator surfaces both hints.
  • 413payload_too_complex (structural limits: depth ≤ 32, ≤ 5000 nodes, arrays ≤ 2000, strings + keys ≤ 32768).
  • 422 — body-size guard (raw bytes > 64 KiB).
  • 429 — Nest throttler (600 req/min per bucket) or the per-project admission window (6000 msgs/60 s + 5000 max queue depth).

MQTT

Topic pattern iot/{deviceId}/telemetry; token goes in the MQTT User- Property (default key x-iot-device-token); QoS 1. Broker URL from transport-config.mqtt.brokerUrl. Same body shape as HTTP.

WebSocket

Socket.io namespace /iot-telemetry, event telemetry, token in socket.io auth: { token: 'iotk_…' } on the handshake. Handshake auth runs IotIngestCoreService.verifyDeviceCredential before the socket ever reaches connection, so an unauthenticated upgrade is rejected at the transport layer.

Kill-flag: IOT_ENABLED

Project-level boolean on Project.setting[]. When false, HTTP + MQTT

  • WS ingest all reject at admission via IotIngestCoreService.verifyIotEnabled. Fail-closed: a Medplum read error is treated as "off" to prevent a transient blip from silently opening a gated project.

Read / write via the existing project-settings surface:

GET /v1/project/settings → includes IOT_ENABLED in the 9-key envelope
PUT /v1/project/settings/IOT_ENABLED → { enabled: boolean }

Console entry: Settings → General → IoT ingestion. When off, the sidebar hides the IoT Builder + Devices + Signals entries and each route renders an empty-state Card with a one-click enable path.

Rule chains

Rule-chains are the per-project DAG that runs on every accepted telemetry message. Each chain has a draft graph (freely editable) and an optional last-published snapshot. Publishing snapshots the draft; rollback restores the last published version into the draft.

The graph model

type RuleGraph = {
nodes: {
id: string;
type: string; // e.g. "trigger.telemetry"
config: Record<string, unknown> | null;
position: { x: number; y: number } | null;
}[];
edges: {
id: string;
source: string; // node.id
target: string; // node.id
sourceHandle: string | null; // 'true' / 'false' on branch nodes
}[];
};

Backend limits: ≤ 200 nodes, ≤ 400 edges, serialized graph ≤ 256 KiB. The console's CodeArea primitive clamps at the same value.

Node catalog

Static registry served from GET /v1/iot-device/rule-chain/node-catalog. Every node's configSchema is a JSON-Schema draft-07 subset that both the console inspector (form generation) and the backend validator (invalid-config issue on Publish) read.

Categories:

  • trigger.* — no inputs, one output. Fire on an incoming event. Two trigger types today: trigger.telemetry (device sent a reading) and trigger.signal (Signals threshold breached).
  • condition.* — one input, branch outputs (true/false handles).
  • action.* — one input, one output (or branch for dedup, switch, and fetch-resource). Do the work.

Full reference at /iot-builder/catalog on the console.

Selector syntax (Zapier-style cross-node references)

Any node config field that names a value on the message accepts one of three prefix-scoped selectors, or a bare key which resolves as if the caller had written payload.<key> (back-compat with early switch chains):

SelectorResolves toExample
payload.<key>message.payload[key]payload.heartRate
metadata.<key>message.metadata[key]metadata.patientRef
nodes.<nodeId>.<key>message.outputs[nodeId][key]nodes.enrich-1.patientRef
<bareKey>message.payload[bareKey]value (equivalent to payload.value)

Dot depth past the first key segment is intentionally forbidden (e.g. payload.a.b treats a.b as a literal key). Keeps parsing linear-scan; matches switch's original contract. Chains that need deeper drilling wire an action.lua node in between.

Consuming nodes: condition.threshold, action.switch, condition.exists, action.dedup (per key), action.set-live-key (via valueFrom).

Producing nodes (declare a produced bag under their node.id):

  • action.enrichdeviceRef, deviceDisplay, patientRef
  • action.fetch-resource → the fetched resource under <outputField>, plus resourceType + id.

Trigger.signal (Signals inbound)

trigger.signal fires after the ordinary Signals OOB / CR reaction completes on the per-project signals processor. The SignalRuleDispatcherService (in ovok-core's rule-engine module) lists every published chain in the project whose root is trigger.signal and runs each synchronously — no queue hop, no retry surface. A rule-chain failure is swallowed inside the dispatcher; the Signals path is never taken down by a bad user chain.

The alert becomes the flat payload:

{
"alertId": "…",
"event": "threshold-breach",
"patient": "Patient/pat-1",
"patientRef": "Patient/pat-1",
"deviceRef": "Device/dev-1",
"code": "8867-4",
"value": 128,
"message": "HR above upper limit",
"observedAt": "2026-07-09T13:12:00Z",
"createdAt": "2026-07-09T13:12:01Z",
"alert": { "…full alert dto…": "…" }
}

Per-run metadata carries triggerKind: 'signal', transport: 'signals-webhook', plus alertId / patientRef / deviceRef / triggerCode / triggerDisplay. Config filters (optional):

{
loinc?: string; // filter on trigger code
event?: string; // filter on alert.event
severity?: 'any' | 'info' | 'warning' | 'critical'; // default 'any'
}

Filters aren't applied yet at the executor level (v1 fires every signal-rooted chain for every alert; author-controlled short-circuits via condition.* are the pragmatic path). The filter fields are reserved so a follow-up can index chains by these without a schema break.

Action.fetch-resource (Zapier-style FHIR read)

Reads a resource from Ovok by id OR single-match search filter, force-scoped to the current project. Every read is capability-gated (fetch-resource in HIGH_RISK_NODE_CLASSES) — default-deny per project until an operator grants it explicitly on Project.extension[iot-enabled-node-classes].

{
resourceType: ClinicalFhirResourceType; // 149 FHIR R5 clinical types
id?: string; // exactly one of…
searchFilter?: { // …id or searchFilter
identifier?: string | number;
code?: string | number;
patient?: string | number;
subject?: string | number;
device?: string | number;
status?: string | number;
date?: string | number;
_lastUpdated?: string | number;
_sort?: string | number;
_count?: string | number;
_offset?: string | number;
};
into?: 'payload' | 'metadata'; // default 'metadata'
outputField?: string; // default 'fetched'
}

resourceType — every FHIR R5 clinical resource is allowed (Patient, Observation, DiagnosticReport, ServiceRequest, Encounter, Immunization, MedicationRequest, DocumentReference, CarePlan, RiskAssessment, Task, … 149 types total). The allowlist is a whitelist, not a blacklist — it explicitly excludes 23 admin / tenancy / security-sensitive types so Medplum shipping a new admin resource never silently widens the attack surface:

Excluded typeWhy
AccessPolicygrants the access rules themselves
AgentMedplum on-premise agent identity
AsyncJobinternal cross-tenant job internals
AuditEventactor / patient refs / IP; cross-tenant discovery
Binaryraw bytes (PDF exports, uploaded docs); PHI exfil
Botexecutable code with elevated privileges
BulkDataExportpre-signed URLs of tenant-wide data snapshots
Bundlecontainer wrapping any resource, including admin
ClientApplicationOAuth client id/secret
DomainConfigurationtenant SSO/OIDC secrets
Endpointaddress + auth headers of outbound integrations
JsonWebKeysigning/verification keys
Loginlive session tokens + refresh tokens
PermissionFHIR R5 access rules (equivalent to AccessPolicy)
Projecttenant root
ProjectMembershiptenancy graph + effective permissions
Provenancechange actor + timeline (companion to AuditEvent)
SmartAppLaunchSMART-on-FHIR launch tokens
Subscriptionwebhook endpoint URLs + secret headers
SubscriptionStatusinternal notification-pipeline bookkeeping
Useridentity: email, hashed password, MFA state
UserConfigurationadmin-plane per-user preferences
UserSecurityRequestpassword-reset / email-verification tokens

The console inspector renders resourceType as a searchable dropdown sourced from the same enum — the FE and the runtime Zod parse never drift.

Guarantees:

  1. resourceType allowlist — admin/tenancy/security resources (23 types above) cannot be fetched by a chain. Attempted use fails Zod at parse time; a defense-in-depth runtime check in the handler routes Failure with metadata.fetchError = 'excluded' if the two constants ever drift.
  2. _project is FORCE-injected into every search — a caller-supplied _project in searchFilter is stripped (Zod rejects it anyway).
  3. Post-read meta.project === ctx.projectId check. Stray cross- project result → routes Failure with metadata.fetchError = 'cross-project'.
  4. Missing resource routes Failure with metadata.fetchError set to 'not-found' (id) or 'no-match' (search). Handler NEVER throws on missing — the chain author gets a proper branch, not a BullMQ retry.

The fetched resource lands under message.metadata[outputField] by default (opt into message.payload[outputField] via into: 'payload') AND on the per-node produced bag, so downstream nodes can address it via nodes.<fetchNodeId>.<outputField>.

Condition.exists

Pairs with fetch-resource. Routes True iff the selector resolves to a non-null value. Config: { from: <selector> }. Zero side effects.

Condition.threshold

Numeric compare using the selector syntax. Config: { from: <selector>, op: '>' | '>=' | '<' | '<=' | '==' | '!=', value: number }. Non-numeric or missing input routes False (Zapier convention — a chain author expects "did this reading breach?" to always be answerable).

The workspace

/iot-builder/[id] is a single-screen authoring workspace, no tabs:

┌───────────────────────────────────────────────────────────────┐
│ ← Chains name · v3 · draft ID Simulate · Safety · Save · Publish │
├──────────┬────────────────────────────────┬───────────────────┤
│ Palette │ Canvas │ Inspector │
│ · filter │ drag from palette / connect │ · schema-driven │
│ · groups │ handles / backspace to delete │ · code editor │
│ · drag │ │ · delete node │
└──────────┴────────────────────────────────┴───────────────────┘
  • Palette (left) — filterable list of catalog nodes grouped by category. Drag any card onto the canvas to add a node with a fresh id + default config.
  • Canvas (middle)@xyflow/react. Kind-tinted custom nodes (trigger = ovok-deep, condition = signal-warn, action = signal-ok). Branch handles labelled true / false on condition.threshold + action.switch + action.dedup. isValidConnection blocks self-loops, edges into triggers, and duplicate (source, target, sourceHandle) combos at drag-time (backend validator is still authoritative on Publish).
  • Inspector (right) — reads the selected node's configSchema and renders the appropriate primitive per field:
    • type: 'boolean' → Toggle
    • enum<select>
    • type: 'number' \| 'integer' → Number Input (with min/max/step)
    • type: 'string' + name looks like code (script/code/lua/sql or format: 'code') → CodeArea (monospace + tab-indent)
    • type: 'string' → text Input
    • object / array → CodeArea JSON fallback

So action.lua opens with a real code editor for the script field, condition.threshold gets a <select> for op + number input for value, trigger.telemetry gets a text input for loinc. Zero raw-JSON exposure unless the schema forces it.

Templates

Three starter templates in the workspace's Load template dropdown (JSON tab). Loading overwrites the graph but preserves name + status. See Sleepiz-style example below for the canonical multi-vital shape.

Safety controls

Two independent brake systems + one capability gate:

Chain breaker (automatic, per-chain)

IotChainBreakerService — tracks consecutive execution failures per (projectId, chainId). When the count crosses IOT_CHAIN_BREAKER_THRESHOLD inside IOT_CHAIN_BREAKER_WINDOW_MS, the disabled key is stamped with its own TTL and subsequent messages fail-open (no-op) instead of retry-storming. Success resets the counter so the breaker measures CONSECUTIVE failures, not lifetime.

Read state via GET /v1/iot-device/observability/chain/:id — the console renders a live-polling chain-breaker card on /iot-builder/[id] (Safety drawer). Trips automatically; no admin action to clear (waits for the TTL).

Killswitch (manual, three scopes)

IotKillswitchService — any truthy Redis value at any of the three keys short-circuits the executor:

  • iot:global:killswitch — global brake (super-admin only).
  • iot:{projectId}:killswitch — per-project brake.
  • iot:{projectId}:killswitch:chain:{chainId} — per-chain brake.

Admin surface:

GET /v1/iot-device/killswitch/:scope[?chainId=…] → status
PUT /v1/iot-device/killswitch/:scope → set/clear

Console: Settings → IoT killswitch (project + global; global gated on super-admin). Chain-scope toggle lives in the workspace's Safety drawer alongside the breaker card.

Fail-open on Redis outage — a Redis blip won't accidentally lock the engine. The operator opts into the strict semantics by choosing Redis health as a dependency.

Capability grants (project-scoped)

High-risk node classes require an explicit operator grant on Project.extension[PROJECT_IOT_ENABLED_NODE_CLASSES_EXT]. Four classes today:

ClassNodesWhat it does
external-webhookaction.webhookPOST to arbitrary external HTTPS. Egress allowlist gated.
execute-botaction.botRun a Medplum server-side bot.
store-fhiraction.store-fhir + action.raise-crWrite Observation / CommunicationRequest.
raise-craction.raise-crSub-grant specifically for raising CRs.

Missing grant → publish 422 with code: capability-denied. The console cross-links the validation report entry directly to the matching row on Settings → IoT capabilities. Grants themselves are super-admin only (write); this admin surface is read-only.

Per-project Signals

Signals is Ovok's alerting / event-bus tier. By default every project routes through the shared Carehub Signals install (source: "global"). Projects can opt into their own tenant via PUT /v1/signals/project-config to isolate event streams and apiKeys.

Console surface: Settings → Signals tenant. Renders three states:

  • Global — read-only "Using shared Signals install" + admin CTA to provision own tenant.
  • Project (provisioned) — read card with baseUrl, tenantId, submitterId, managerId, subscriberId, hasApiKey, hasWebhookSecret + three actions:
    • Test connection — non-mutating probe; renders authOk + subscriberOk + source chips.
    • Rotate webhook secret — new secret shown ONCE.
    • Revoke — typed-tenant-id confirm; reverts to global.
  • Partial — red banner "Signals config inconsistent — complete or delete."

Webhook signing secret is returned exactly once on both provision and rotate; same once-visible KeyRevealPanel primitive as device tokens.

Signals-landed observations

GET /v1/signals/observations?patient=&code=&_since=&_count= returns Observations whose identifier[] includes a system starting with SIGNALS_SYSTEM (https://signals.actimi.com/*) — filters to both /observation and /alert identifier flavors. Cross-references CommunicationRequests via basedOn.

Console: Signals (top-level sidebar). List + drill-in showing the full Observation JSON + Signals-specific extensions (payload, message, value, reason) + linked CRs.

Example: Sleepiz-style ingestion flow

Ships as the default "Load template" pick on the JSON tab. Full eight-node multi-vital shape the sleepiz project uses in production (see DeviceInitializationRunner

Node-by-node

  • trigger.telemetry — fires on any device telemetry matching the LOINC filter. 8867-4 = heart rate. Swap for 9279-1 (breathing rate), 59408-5 (SpO2), or 81509-1 (out-of-bed) to reuse the same graph for other vitals.
  • action.enrich — reads the Device (by the token-derived id)
    • its linked Patient; attaches both as metadata so downstream nodes get deviceRef + patientRef for free. Self-serve.
  • action.dedup — first-seen (true) vs repeat (false). Keyed on deviceId + ts with label sleepiz-hr-1min. Repeats dead-end silently on the false handle. Self-serve.
  • action.switch — branching threshold. from: "value", op: "gt", value: 120 — HR above 120 bpm routes true, everything else false. Self-serve.
  • action.raise-cr — creates CommunicationRequest with priority: urgent. High-risk — needs the raise-cr grant.
  • action.store-fhir — persists as Observation on the FHIR store. High-risk — needs the store-fhir grant.
  • action.send-to-signals — resolves the project's Signals tenant + stamps tenant identity. Self-serve.
  • action.set-live-key — writes the payload's value to Redis under code hr for last-known-value dashboards. Self-serve.

Adapting

Two knobs per deployment:

  1. LOINC on the trigger — swap 8867-4 for the vital your device reports. The rest of the chain is code-agnostic.
  2. Threshold on the switch120 is the HR bound; use < 12 for BR bradypnea, < 90 for SpO2 desat, etc.

Everything else — the enrich → dedup → fan-out shape — carries over verbatim. Same shape sleepiz runs against Hartmann-tenant production traffic today, just parameterized.

Testing with the simulator

The console ships a raw HTTP telemetry simulator that fires real ingest requests — same wire path a production device uses, no FHIR detour. Available two places:

  1. /devices/[id]TelemetrySimulator card below the transport picker. Device is already known.
  2. /iot-builder/[id]Simulate button on the workspace action bar → right-side drawer, payload seeded from the first trigger.telemetry node's config.

End-to-end recipe

  1. Load/iot-builder/[chain-id] → JSON tab → Load template → Sleepiz-style ingestion (HR) → Save.
  2. GrantsSettings → IoT capabilities. If raise-cr + store-fhir aren't granted, request them; publish otherwise 422s with capability-denied.
  3. Provision/devices+ New device → serial SLPZ-TEST-01 (any string). Mint the token; copy iotk_… once.
  4. Send — on /devices/[id], in the Simulate card, paste the token + POST a body like:
    {
    "value": 145,
    "unit": "bpm",
    "deviceId": "SLPZ-TEST-01",
    "ts": "2026-07-09T12:00:00Z"
    }
  5. Verify — response panel shows 202 + jobId. On the ovok-core side, a CommunicationRequest (priority: urgent) lands on the project, an Observation lands, and the Signals tenant receives the event.

Response decoding

The simulator decodes ovok-core status codes with concrete hints:

StatusMeaningHint
202AcceptedjobId = internal messageId (used for dedup + trace correlation).
401UnauthorizedBad token OR IOT_ENABLED=false. Deliberately indistinguishable server-side; check both.
413Payload too complexStructural limits: depth ≤ 32, ≤ 5000 nodes, arrays ≤ 2000, strings ≤ 32768.
422Body too largeRaw bytes > 64 KiB. The composer clamps at the same value client-side.
429Rate limitedHTTP throttler (600/min) or admission window (6000/min + 5000 queue depth).

Token is held in memory only — never persisted to localStorage, never sent anywhere but ovok-core through the console's server-side proxy.

Console surfaces

Full URL map for the IoT feature set:

URLPurpose
/iot-builderRule-chain list. Row → detail. + New chain opens a minimal empty-graph draft.
/iot-builder/[id]Full-page authoring workspace — palette + canvas + inspector. Simulate + Safety drawers.
/iot-builder/catalogRead-only node-catalog reference. Copy-stub button per entry.
/devicesDevice list. + New device opens the S/N-keyed provision modal.
/devices/[id]Token panel (mint/rotate/revoke) + three-tab transport picker + observability rail + telemetry simulator.
/signalsSignals-landed observations list with filters.
/signals/[observationId]Full observation JSON + Signals extensions + linked CRs.
/settings/generalProject settings incl. IOT_ENABLED toggle.
/settings/iot/signalsPer-project Signals tenant provisioning + rotate + test + revoke.
/settings/iot/capabilitiesRead-only high-risk capability grants.
/settings/iot/killswitchProject + global killswitch (chain-scope on the chain workspace).

Sidebar visibility for IoT entries respects IOT_ENABLED: when off, the Project section drops the IoT Builder / Devices / Signals rows and each route renders an empty-state Card with a one-click enable path.

API reference

Full HTTP contract auto-generated from the ovok-core Swagger dump into High Level API → IotDevice/Admin and High Level API → Signals. Endpoint index here for the operator-facing view:

MethodPathPurpose
POST/v1/iot-device/provisionCreate (or match existing) Device/<S/N>. Idempotent.
GET/v1/iot-device/devicesList Devices scoped to caller's project.
POST/v1/iot-device/devices/:id/tokenMint iotk_… (once-visible).
POST/v1/iot-device/devices/:id/token/rotateRotate iotk_… (once-visible).
DELETE/v1/iot-device/devices/:id/tokenRevoke iotk_… (Device stays).
GET/v1/iot-device/transport-configHTTP/MQTT/WS discovery (no secrets).
GET/v1/iot-device/observability/device/:idlastTelemetryAt + brake state.
GET/v1/iot-device/observability/chain/:idBreaker + brake state per chain.
GET/v1/iot-device/capabilitiesHigh-risk grants for caller project.
GET/PUT/v1/iot-device/killswitch/:scopeRead/set killswitch.
GET/v1/iot-device/rule-chainList rule-chains.
POST/v1/iot-device/rule-chainCreate draft chain.
GET/v1/iot-device/rule-chain/node-catalogStatic node registry.
GET/PUT/v1/iot-device/rule-chain/:idRead / update draft.
POST/v1/iot-device/rule-chain/:id/validateLint the draft. Always 200.
POST/v1/iot-device/rule-chain/:id/publishSnapshot draft as published. 422 on invalid.
POST/v1/iot-device/rule-chain/:id/rollbackRestore published into draft.
POST/v1/iot-device/telemetryHTTP ingest. x-iot-device-token header.
GET/v1/signals/project-configPer-project Signals config (no secret echo).
PUT/v1/signals/project-configProvision per-project Signals (returns secret ONCE).
POST/v1/signals/project-config/rotate-webhook-secretRotate secret (ONCE).
POST/v1/signals/project-config/test-connectionNon-mutating probe.
DELETE/v1/signals/project-configRevoke → back to global routing.
GET/v1/signals/observationsSignals-landed observations list.

Environment configuration

Deployment-level env vars the IoT surface reads:

Env varPurposeDefault
OVOK_PUBLIC_URLHTTP + WS public endpointFalls back to OVOK_INTERNAL_PUBLIC_URL
MQTT_BROKER_URLMQTT broker URLNot configured — MQTT off
MQTT_TELEMETRY_TOPIC_PATTERNTopic layoutiot/{deviceId}/telemetry
MQTT_DEVICE_TOKEN_USER_PROPERTYMQTT User-Property keyx-iot-device-token
MQTT_QOSPublish QoS1
IOT_HTTP_MAX_BODY_BYTESHTTP body cap65536 (64 KiB)
IOT_WS_NAMESPACEsocket.io namespace/iot-telemetry
IOT_WS_EVENT_NAMEsocket.io eventtelemetry
IOT_CHAIN_BREAKER_THRESHOLDConsecutive fails to tripSee consts
IOT_CHAIN_BREAKER_WINDOW_MSRolling windowSee consts
IOT_ADMISSION_MAX_PER_WINDOWPer-project ingest cap6000
IOT_ADMISSION_WINDOW_MSRolling window60000
IOT_MAX_QUEUE_DEPTH_PER_PROJECTBounded queue depth5000

GET /v1/iot-device/transport-config echoes the discovery values back to the console — no console-side env-var duplication.

Debugging

"Waiting on ovok-core deploy"

When the deployed ovok-core doesn't yet know an endpoint the console calls, Nest returns { message: "Cannot GET /v1/…", ... }. The console's isRouteMissing helper (lib/api.ts) recognizes this specific 404 shape and renders a friendly "Waiting on ovok-core deploy" card citing the exact backend commit that ships the missing endpoint. So a stale alpha environment never surfaces as a mysterious raw-Nest error.

Pages with the soft-degrade wired: /signals, /devices, /devices/[id], /iot-builder/[id], /settings/iot/capabilities, /settings/iot/killswitch.

Recent additions timeline

Ovok-core commitShipsConsole consumer
42b0f0c7b0 · IOT_ENABLED kill-flagS-GRAFT-0 · /settings/general toggle
8ff3d44cb1+b2+b3 · devices + token routes + transport-configS-GRAFT-2 · /devices + transport picker
ce16bcbeb5+b6 · killswitch write + capabilities readS-GRAFT-3+4 · killswitch + capabilities pages
e3e1402cb4 · observability aggregates + heartbeatS-GRAFT-3 · device rail + chain breaker
4c73052bb7 · signals-observations listS-GRAFT-6 · /signals
d57166faAdmin device provisioning/devices "+ New device"
4e1e9d4bProvision keyed by serialNumber (sleepiz pattern)Console dialog with S/N field
984cfd0bRailway config pinDeploy verification

References