NeccessoryNeccessory
Cloud

Cloud Quickstart

Mint a session on your backend, hand the token to the page, and take a cloud measurement.

A cloud measurement takes two moving parts: your backend mints a short-lived session with your key, and your frontend runs the SDK with the token it was given. The secret never reaches the browser.

your backend ──NCS1──> POST /sdk/v1/cloud/sessions
                       ← data = { sessionId, sessionToken, wsUrl, frameSpec, … }
        │
        └── the whole `data` ──> your page ──WSS──> face crops ──> live events ──> result

1. Mint a session on your backend

POST /sdk/v1/cloud/sessions, signed with the same NCS1 HMAC scheme as every other /sdk/v1 call. The key must be active.

// request
{
  "platform": "web",               // "web" | "ios" | "android"
  "durationSec": 60,               // 20 .. 180, default 60
  "features": ["hrv", "bp", "spo2"], // a subset; core metrics are always on
  "consent": true,                 // REQUIRED — see below
  "clientRef": "user-42"           // optional, ≤ 64 chars, your own correlation id
}
// 201
{
  "ok": true,
  "data": {
    "sessionId": "cj_…",
    "sessionToken": "<payload>.<signature>",
    "wsUrl": "wss://infer.neccessory.com/v1/stream",
    "expiresAt": 1785000000000,
    "maxDurationSec": 60,          // echoes the durationSec you asked for
    "features": ["hrv", "bp", "spo2"], // the sets this session will compute and bill
    "frameSpec": {
      "kind": "face-crop",
      "width": 36, "height": 36, "channels": 3,
      "layout": "nhwc", "dtype": "uint8", "colorSpace": "srgb",
      "padRatio": 0,
      "nominalRateHz": 30, "maxRateHz": 32,
      "compression": "none"
    }
  }
}

maxDurationSec is the duration this session was actually granted, not the tier ceiling: ask for 60 and you get 60 back. The ceiling — 180 s — only appears as the upper bound of invalid_duration.

import crypto from 'node:crypto';

const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');

function signHeaders(keyId, secret, method, path, body = '') {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(12).toString('base64url');
  const canonical = ['NCS1', method.toUpperCase(), path, timestamp, nonce, sha256(body)].join('\n');
  return {
    'x-key-id': keyId,
    'x-timestamp': timestamp,
    'x-nonce': nonce,
    'x-signature': crypto.createHmac('sha256', sha256(secret)).update(canonical).digest('base64'),
    'content-type': 'application/json',
  };
}

const path = '/sdk/v1/cloud/sessions';
const body = JSON.stringify({
  platform: 'web',
  durationSec: 60,
  features: ['hrv', 'bp', 'spo2'],
  consent: true,
});

const res = await fetch(`https://neccessory.com${path}`, {
  method: 'POST',
  headers: signHeaders(process.env.NC_KEY_ID, process.env.NC_SECRET, 'POST', path, body),
  body,
});
const { data } = await res.json();
// Return the whole `data` object to the page. It is all public: the token, the
// socket address, the session id and the frame spec. Your secret is not in it.
return Response.json(data);

Hand the page the whole `data` object

The session token alone is not enough to run a measurement. The SDK also needs wsUrl to know where to stream and frameSpec to crop exactly the way the server expects, and neither is recoverable from the token. Forward only sessionToken and the measurement fails with no_ws_url before the camera even opens — in every delivery mode except batch.

(sessionId and the granted duration are readable from the token's claims, so the SDK falls back to those. wsUrl and frameSpec have no such fallback.)

Nothing in data is a credential beyond the session itself: the token is scoped to one session, is single-use, and expires in 300 seconds.

`consent: true` is mandatory

The cloud tier transmits images of the user's face. A request without consent: true is rejected with 400. Collecting and recording that consent is your responsibility as the integrator; the API only refuses to proceed without your assertion that you have it. See Cloud privacy.

About the token

  • It is base64url(payload).base64url(signature) — a signed statement of session id, key id, organization, platform, allowed duration, and features.
  • It is short-lived: 300 seconds by default. Mint it when the user is about to measure, not when the page loads.
  • It is single-use: a second connection with the same session id is rejected with session_already_used.
  • It is safe to give to the browser. It is not the secret and it cannot be used to sign anything else.

2. Run the measurement in the browser

Fetch the session object from your own endpoint and pass it straight through as session. The SDK reads sessionToken, sessionId, wsUrl, maxDurationSec and frameSpec out of it — you never unpack them yourself.

<video id="cam" autoplay playsinline muted width="480"></video>
<p>Heart rate: <span id="hr">--</span> bpm</p>
<p id="status"></p>
<button id="start">Start</button>

<script type="module">
  import { createMeasurement } from '@neccessory/web-sdk';

  // The whole `data` object from POST /sdk/v1/cloud/sessions, forwarded verbatim
  // by your backend: { sessionId, sessionToken, wsUrl, maxDurationSec, frameSpec }.
  const session = await fetch('/your-backend/nc-session', { method: 'POST' })
    .then((r) => r.json());

  const m = createMeasurement({
    videoEl: document.getElementById('cam'),
    mode: 'cloud',
    session,
    delivery: 'auto',
  });

  m.onRealtime((update) => {
    if (update.heartRate != null) {
      document.getElementById('hr').textContent = update.heartRate;
    }
  });

  m.onStage((stage) => {
    // 'requestingCamera' | 'loadingModels' | 'connecting' | 'ready'
    // and, when streaming falls back to batch, 'uploading' then 'processing'.
    document.getElementById('status').textContent = stage;
  });

  m.onResult((result) => {
    // result.origin === 'cloud', result.attested === true
    console.log(result);
  });

  m.onError((err) => console.error(err.code, err.message));

  document.getElementById('start').addEventListener('click', () => m.start());
</script>

If you would rather keep the pieces apart — for example because your endpoint returns them under different names — pass sessionToken, sessionId and wsUrl as individual options instead. Each one overrides the matching field of session. Omitting wsUrl is only safe with delivery: 'batch'; anything else fails with no_ws_url.

There is no init({ keyId, secret }) in cloud mode and no model grant: the token carries the authorization, and there are no model weights to download.

3. Read the result

The result is the same measurement result you get on-device, with two extra fields:

{
  "origin": "cloud",
  "attested": true,
  "quality": { "level": "good", "score": 0.82, "usable": true }
  // … all the usual sections
}

The measurement is already stored under your key by the time you see it — the inference service writes it. You do not call POST /sdk/v1/measurements for a cloud measurement; that endpoint is for on-device results. See Attestation.

What can go wrong

SituationWhat you see
Token older than its TTLtoken_expired
Token reusedsession_already_used
Page got sessionToken but no wsUrlno_ws_url before the camera opens (any delivery but batch)
NC_INFERENCE_WS_URL not configured on our side503 cloud_unavailable at mint time
More than 32 frames per secondframe_rate_exceeded
No packets for 10 secondsidle_timeout
Signal too weak for a real answerA final with quality.level: "poor", isComplete: false, all metrics null

The last row is deliberate. neccessory does not fabricate numbers: a poor capture returns "no measurement", not a plausible-looking one.

The complete list is in Limits & errors.

Next: Streaming protocol.