Submitting Measurements
POST a completed measurement to the SDK API, and GET it back by id.
This endpoint is for the on-device tier. There the SDK runs the whole pipeline locally, so there is no session to open and no video to upload: when a measurement completes, you submit its result to a single endpoint, which stores it and attributes it to your key.
Cloud measurements are not submitted
In the cloud tier the metrics are computed on our
servers and the measurement is written before you ever see it. There is nothing
to POST. Cloud results carry origin: "cloud" and attested: true; results
submitted here carry origin: "on_device" and attested: false, because the
client computed them — see Attestation.
All SDK-facing endpoints are under /sdk/v1 and require
HMAC authentication. Responses use the standard
envelope:
{ "ok": true, "data": { } }{ "ok": false, "error": { "code": "string_snake", "message": "human readable" } }Submit a measurement
POST /sdk/v1/measurements
Persists a completed measurement and attributes it to the key. The request body
is the measurement result shape; source is
required.
Body (abbreviated — see the full measurement result):
{
"source": "web",
"durationSec": 32.4,
"isComplete": true,
"sdkVersion": "0.1.0",
"modelsVersion": "wv-5",
"quality": { "level": "good", "score": 0.82, "usable": true },
"cardiac": { "heartRate": { "value": 72, "unit": "bpm", "confidence": 0.88 } },
"hrv": { "sdnnMs": 48, "rmssdMs": 41, "pnn50Pct": 12.5 },
"respiratory": { "breathingRate": { "value": 15, "unit": "brpm" } },
"stress": { "index": 34, "level": "low", "unit": "index" },
"bloodPressure": { "systolicMmHg": 118, "diastolicMmHg": 77, "unit": "mmHg", "confidence": 0.51, "tier": "beta" },
"spo2": { "value": 97, "unit": "%", "confidence": 0.60, "tier": "beta" }
}Response — the stored measurement, with an assigned id:
{ "ok": true, "data": { "id": "meas_9c1f4b7ae2d05138b6a37f42", "keyId": "...", "quality": { "...": "..." }, "cardiac": { "...": "..." } } }const res = await fetch('https://neccessory.com/sdk/v1/measurements', {
method: 'POST',
headers: signHeaders(keyId, secret),
body: JSON.stringify(result), // the completed measurement
});
const { data } = await res.json();Naming the engine that produced the result
Two optional body fields say which build made the measurement:
| Field | What it is |
|---|---|
sdkVersion | Version of the client SDK that produced the result. |
modelsVersion | Version of the model bundle it ran — for the web SDK, the identifier of the engine assets it loaded. |
Both are strings of 1–64 characters matching ^[A-Za-z0-9][A-Za-z0-9._+-]*$
("0.1.0", "wv-5", "2026-08-01+web"). A value outside that shape fails the
whole request with 400 validation_error — it is never silently dropped. Both
fields are optional: a request without them is stored exactly as before, so
clients built against the earlier contract keep working unchanged.
They are part of the signed body. The signature covers a SHA-256 of the body you send, so add the fields before signing; nothing about the canonical string changes — see Authentication.
The web SDK does this for you. @neccessory/web-sdk stamps its own
SDK_VERSION on every result it maps and every body it builds, and the on-device
driver reports the model bundle it actually loaded (exported as
ON_DEVICE_MODELS_VERSION), so submit() and signAndSubmit() already carry
both:
import { createMeasurement, SDK_VERSION } from '@neccessory/web-sdk';
const m = createMeasurement({ videoEl, modelGrant });
m.init({ keyId, secret });
m.onResult(async (r) => {
console.log(r.sdkVersion, r.modelsVersion); // "0.1.0" "wv-5"
await m.submit(r); // both travel inside the signed body
});Set the fields yourself — on the result before submitting, or straight in the body — when you wrap the SDK in your own client or run your own capture stack; an explicit value wins over the built-in one. Cloud measurements need nothing: we computed them, so we already know the versions.
The stored measurement returns them back on GET, and the console groups
on-device volume and quality by them.
Opting into raw data
By default the response omits the raw block (RR intervals and pulse signal). To
persist and return it, opt in with either the query string or the body:
POST /sdk/v1/measurements?advanced=true{ "source": "web", "includeAdvanced": true, "raw": { "rrIntervalsMs": [...], "ppgSignal": [...], "sampleRateHz": 30 } }Read a measurement back
GET /sdk/v1/measurements/{id}
Returns one stored measurement. The record belongs to the key that stored it:
signing with another key of the same account returns a plain 404, so an id is
never a hint that the row exists. The query flags match the POST — advanced=true
adds the raw block if it was persisted, include=assessment adds the wellness
assessment.
GET /sdk/v1/measurements/meas_9c1f4b7ae2d05138b6a37f42?include=assessmentThe signature covers the path without the query string and an empty body — see Authentication.
import { signAndFetchMeasurement } from '@neccessory/web-sdk';
const measurement = await signAndFetchMeasurement({
keyId,
secret,
measurementId: 'meas_9c1f4b7ae2d05138b6a37f42',
includeAssessment: true,
});The same call by hand:
const path = `/sdk/v1/measurements/${id}`;
const res = await fetch(`https://neccessory.com${path}`, {
headers: signHeaders(keyId, secret, { method: 'GET', path, body: '' }),
});
const { data } = await res.json();The secret belongs on a server
Reading a measurement needs the key secret, exactly like submitting one. Call this from your backend, not from a page you ship to end users.
Verify credentials
POST /sdk/v1/auth/test verifies your HMAC signature and returns the key's
facts — id, status, mode, features, limits, quotas, token balance, organization
and server clock. A quick check that signing works. See
Authentication.
Public discovery endpoints
No authentication required:
| Endpoint | Purpose |
|---|---|
GET /health | Liveness — { ok: true, version }. |
GET /openapi.json | OpenAPI 3.1 specification of the API. |
GET /.well-known/agent.json | Agent manifest (capabilities, auth). |
GET /api/v1/pricing | Token rates, metric sets and the purchasable packs — see Pricing. |
GET /api/v1/limits | The server's own limit constants, so you need not hardcode ours: keyRateLimitPerMinMax, orgWebhookLimit, webhookAttempts, measurementBaseDurationSec, cloudMinDurationSec, cloudMaxDurationSec, exportRowCap, maxWindowDays. |
The rest of /api/v1 is the console's own API. It authenticates with a console
session, not with an API key, so a nk_live_ key cannot reach it and it is not
documented here.
Typical flow
- Run the on-device measurement (Web or iOS SDK).
- On completion,
POST /sdk/v1/measurementswith the result, HMAC-signed. - Read the stored record back with
GET /sdk/v1/measurements/{id}, or from your account. - When something goes wrong, look the call up by its
X-Request-Idin the usage log.
origin and attested are set by the server and cannot be supplied by a client.
If you send them they are ignored — a submitted measurement is always
on_device and never attested.
For the cloud endpoints — POST /sdk/v1/cloud/sessions, the upload presign, and
the batch job endpoints — see Cloud quickstart and
Batch processing.
Next: Errors.