How UniAsset Is Building Enterprise IoT Asset Intelligence
UniAsset's product surface has, until recently, been about the state of an asset — its identity, its assignments, its maintenance history, its TCO. That model is correct but incomplete. State is what you know between events. Most operational risk lives in the events themselves.
This post walks through how we are extending the platform to absorb those events, starting with the IoT Signal foundation released as Phase 1.
Design principles
We started by writing down what the foundation must not do, because IoT projects tend to fail the moment they try to do too much at once. The non-goals were:
- No automation engine in Phase 1. No rules, no auto-created work orders, no SLA escalations driven by signals.
- No real-time push UI. No WebSockets, no SSE.
- No predictive scoring. No ML, no anomaly detection.
These are all explicitly on the roadmap. They are explicitly not Phase 1.
What Phase 1 must do:
- Accept signals from any external IoT pipeline via authenticated REST.
- Store every signal under the originating tenant.
- Link signals to assets where possible, preserve them where not.
- Display signals in two places: the asset detail page and a global explorer.
- Audit every ingestion.
- Gate the feature to Enterprise.
That list is small enough to ship with confidence and broad enough to be useful immediately.
The hardest part of building IoT functionality into an existing SaaS platform is resisting the urge to ship everything at once. The substrate has to be right before the automation can be right.
The ingestion endpoint
We exposed a single endpoint:
POST /api/integrations/iot/signals
Authorization: Bearer ua_live_<key>
Content-Type: application/json
The body is intentionally minimal:
{
"assetId": "optional",
"deviceId": "optional",
"source": "AzureIoTHub",
"signalType": "temperature.high",
"severity": "HIGH",
"numericValue": 92.5,
"payload": { "temperature": 92.5, "unit": "C" }
}
Three rules govern the shape:
source,signalType, andpayloadare required. Everything else is optional. Signals can arrive before an asset is registered; gateways often don't know the asset-to-device mapping.- The raw payload is the source of truth.
numericValue,textValue, andbooleanValueare typed projections for indexing. The audit/replay path always reads frompayload. tenantIdis never accepted from the body. It is resolved server-side from the API key, the same way the rest of UniAsset's integration endpoints behave.
The endpoint uses the existing withIntegrationController wrapper, so it inherits API-key authentication, Enterprise plan enforcement, 1 MB payload limits, audit logging, and standardized error responses.
The data model
A single new table — IoTSignal — holds the stream:
model IoTSignal {
id String @id @default(cuid())
tenantId String
assetId String?
source String
externalDeviceId String?
signalType String
severity String?
numericValue Float?
textValue String?
booleanValue Boolean?
processed Boolean @default(false)
payload Json
receivedAt DateTime @default(now())
@@index([tenantId, receivedAt])
@@index([assetId, receivedAt])
@@index([signalType])
@@index([processed])
}
Two of those decisions are worth highlighting:
receivedAt, not occurredAt
The timestamp on every row is the server-side receive time. We do not trust a device-supplied clock for ordering — clock drift on remote hardware is the rule, not the exception. If the device-supplied time matters to a particular signal type, the integrator stores it inside payload.
processed exists today, but is never written
The processed boolean and its index are in the schema from day one even though Phase 1 never sets it to true. We did this deliberately: when the Phase 2 rules engine ships, it can land without a schema migration. The (processed) index is in place for the unprocessed-queue scan that the rule worker will perform.
This is the kind of forward-positioning that keeps a young feature evolvable.
Tenant isolation
Every query in core/iot-signals requires a tenantId and includes it in the WHERE clause. There is no helper that "looks up across tenants" — that escape hatch is precisely the kind of code that gets shipped in a hurry, gets misused six months later, and surfaces as a bug bounty report.
The Prisma cascades make this concrete:
Tenant▶IoTSignalisON DELETE CASCADE. Tenant deletion takes the stream with it (GDPR right-to-erasure works automatically).Asset▶IoTSignalisON DELETE SET NULL. Archiving an asset does not destroy its historical telemetry.
The two UI surfaces
The per-asset Signals section
Lives on the asset detail page, alongside Maintenance, Incidents, Documents, and the Activity Timeline. Shows the most recent 10 signals with a "View all" link to the global explorer pre-filtered to this asset.
Non-Enterprise tenants see an upsell card in the same slot. The query does not run for them — the empty state communicates the value of the feature without consuming database time.
The global explorer at /dashboard/iot-signals
Enterprise-only. The sidebar nav item is hidden for non-Enterprise plans entirely; the page itself does a second plan check on render so direct-URL access surfaces the upsell instead of querying.
Filtering is implemented as a plain GET form. We deliberately avoided client-side filter state for Phase 1 — the URL is the state, which means it can be bookmarked, shared in incident reports, and back-button-navigated. That trade-off costs us a redraw per filter change; for an investigative tool that is the right call.
Cursor pagination, not OFFSET, is used throughout. The opaque cursor is the id of the last row on the previous page, which lets us paginate against the (tenantId, receivedAt) index even at very high row counts without paying for an OFFSET scan.
What this enables next
The Phase 1 contract — POST /api/integrations/iot/signals plus the IoTSignal table shape — is the stable surface that everything else hangs off:
- Rules engine. Pattern-matches
signalType,severity, and value thresholds. Emits side-effects (work orders, incidents, notifications). Marks signalsprocessed = true. - Real-time push. Add an SSE route that emits new rows after
IoTSignal.create. No schema change. - Predictive maintenance. Read historical streams via
(assetId, receivedAt), feed a scoring model, surface "due for service" suggestions on the asset card. - AI correlation. Replay the verbatim
payloadthrough a model that clusters related signals so a single underlying fault doesn't generate ten duplicate tickets. - Retention. A cron route iterates active tenants, drops signals past the per-tenant TTL, respects the
processedflag so in-flight signals are preserved.
Every one of those is a feature in its own right. None of them require us to re-think Phase 1.
What we learned shipping this
Three things are worth recording explicitly:
Severity is hard to standardize. Industrial systems do not agree on what HIGH means. We normalize to upper case server-side and store anything that arrives, including vendor-specific levels. Reconciliation is a tenant-side concern, not a platform-side one.
Signals without an asset link still have value. We almost made assetId required and almost regretted it. Plenty of pipelines emit events before they know the asset mapping. Allowing the signal to land, then reconciling later, is the right model.
The raw payload pays for itself. Storing it verbatim doubles the row size for some workloads, and we hesitated. But the first time a customer asked us to replay six weeks of signals through a different transform, the decision was vindicated.
Closing thought
Building IoT functionality into an existing asset management platform is a discipline. The temptation is to ship a flashy dashboard early and back-fill the architecture. We took the opposite approach: ship the substrate, keep the surface small, and tell the customer plainly that Phase 1 is foundation work.
The next post in this series will be about the rules engine — what shape it takes, what trade-offs we made, and how it composes with this foundation.
See also: Why Modern Asset Management Needs IoT Signal Visibility and Azure IoT Hub + UniAsset: Enterprise Asset Monitoring Simplified.
Ready to put this into practice?
Start tracking your assets, scheduling maintenance, and gaining operational insights today.