Building a Client App
Subscribe to live insights from the user's phone or web app.
The client app is the part your end user actually sees: a phone app showing live heart rate, a web dashboard showing respiratory trends, a smart-mirror UI. It talks to /stream/subscribe and never touches raw sensor data.
Connect
wss://api.raeh.io/stream/subscribe?api_key=<raeh_...>That's the whole URL. No account_id to pass; the key determines which account's insights you receive.
The first message from the server is a small JSON ack you can discard:
{ "status": "subscribed" }Every message after that is an insight.
The insight payload
{
"type": "hr",
"value": 72.4,
"unit": "bpm",
"ts": 1760000000000,
"session_id": "ad225407-36de-48b1-bcf2-14d5c057aeca",
"device_id": "xyz-watch-a3b5",
"confidence": 0.87,
"sqi_class": "excellent"
}| Field | Meaning |
|---|---|
type | One of hr, spo2, rr. |
value | Numeric reading in the unit below. |
unit | bpm, %, or brpm. |
ts | Insight publish time in ms since epoch (useful for end-to-end latency). |
session_id | The session this insight belongs to. |
device_id | The physical unit it came from (same string you passed to /stream/ingest). |
confidence | Signal Quality Index, 0..1. How much of the analysis window had fresh, low-gap data. |
sqi_class | Discrete bucket: "excellent" / "acceptable" / "unfit". Use this for UI gating. |
If your UI only cares about HR, filter on msg.type === "hr" and ignore the rest. New insight types may appear in future, so design your code to tolerate unknown type values.
Gate the UI on sqi_class
A bare number is easy to render but risks misleading the user when the signal is poor. Handle the three buckets explicitly:
function renderInsight(el, msg) {
if (msg.sqi_class === "unfit") {
el.textContent = "–";
el.style.opacity = 0.4;
el.title = "Signal quality low, estimating";
return;
}
el.textContent = msg.value.toFixed(msg.type === "hr" ? 0 : 1);
el.style.opacity = msg.sqi_class === "excellent" ? 1.0 : 0.75;
el.title = `confidence ${msg.confidence.toFixed(2)}`;
}For clinical or safety-critical flows, consider a stricter rule: ignore value when confidence < 0.6 entirely rather than showing it dimmed. For fitness / lifestyle apps, dimming on "acceptable" and blanking on "unfit" is usually plenty.
Subscription scope
The subscription is scoped to your account, not a session. If one of your users has a watch streaming on session A and opens the app, they'll get session A's insights. If they later switch to a second watch (session B), those arrive on the same subscription. No reconnect needed.
For per-user apps where the key is minted per end-user, each user's subscription only sees insights tied to the sessions their devices created. You don't have to filter client-side.
Stay connected
The subscription is meant to be long-lived. Keep it open while the app is in the foreground. Closing and re-opening on every view change wastes battery and adds reconnect latency before the first insight.
Practical pattern for a mobile app:
- Open the subscription when the user enters the "live session" view.
- Keep it open while in foreground.
- Close it when backgrounded (depending on your platform's WebSocket-in-background support).
- Reopen on resume.
Reconnection
On disconnect, reconnect with exponential backoff (start at 1s, cap at 30s). You'll miss insights published during the gap; they're not replayed. If missing a few seconds of data is a problem, cache the last known values locally and consider the connection "live" on reconnect.
Handling session boundaries
A session ends when the wearable disconnects. If you want to show "session ended" state in the UI, watch for the absence of new insights for ~15 seconds; simpler than any protocol-level signal.
(Raeh emits a session lifecycle event internally, but it's currently only on the operator debug channel, not on the client subscribe channel.)
What insights you can expect
With PPG at 100 Hz and a reasonably clean signal:
| Type | Typical cadence | Notes |
|---|---|---|
hr | every 2 s | Starts ~15 s after session opens, once the window is filled. |
spo2 | every 5 s | Stub today (returns 98). Red/IR implementation coming. |
rr | every 5 s | Derived from HRV. Needs ~20 s of stable HR to stabilize. |
Under motion or poor skin contact, the pipeline may skip a window and you'll see a gap. That's expected; don't panic the UI on a 5-second pause.
A minimal browser client
<script>
const key = "raeh_...";
const ws = new WebSocket(`wss://api.raeh.io/stream/subscribe?api_key=${encodeURIComponent(key)}`);
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "hr") document.getElementById("hr").textContent = msg.value.toFixed(0);
};
</script>
<div>HR: <span id="hr">–</span> bpm</div>For a fuller example (HR, SpO₂, RR cards with event log), see Examples → JavaScript.