raehDocs
Guides

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"
}
FieldMeaning
typeOne of hr, spo2, rr.
valueNumeric reading in the unit below.
unitbpm, %, or brpm.
tsInsight publish time in ms since epoch (useful for end-to-end latency).
session_idThe session this insight belongs to.
device_idThe physical unit it came from (same string you passed to /stream/ingest).
confidenceSignal Quality Index, 0..1. How much of the analysis window had fresh, low-gap data.
sqi_classDiscrete 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:

TypeTypical cadenceNotes
hrevery 2 sStarts ~15 s after session opens, once the window is filled.
spo2every 5 sStub today (returns 98). Red/IR implementation coming.
rrevery 5 sDerived 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.

On this page